Skip to content

refactor: migrate is to the request-bound device runtime - #1883

Draft
thymikee wants to merge 9 commits into
agent/wave4-getfrom
agent/wave4-is
Draft

refactor: migrate is to the request-bound device runtime#1883
thymikee wants to merge 9 commits into
agent/wave4-getfrom
agent/wave4-is

Conversation

@thymikee

@thymikee thymikee commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Important

This PR deliberately reverses a merged decision (#557), on thymikee's explicit instruction.
is on a simple iOS selector reported a failing assertion as a successful command. See
Reversal of #557 below — it is not incidental
to the migration and should be reviewed on its own terms.

Wave 4 / ADR 0019 unit: migrates the canonical is descriptor off platformExecution: LEGACY_PLATFORM_EXECUTION onto the request-bound device runtime. Cutover rule R37.

Stacked base. This branch is stacked on agent/wave4-get at 4778a27512ee2e1c8e8c0efb7833ee4da908f792 (itself on #1876 30435df1b, itself on origin/main 8c06965d2). It consumes the selector capture seam that unit owns — resolveBoundSelectorCapture, createBoundSelectorRuntime, admitAndBindSnapshotCapture, CaptureSnapshotInput.signal, and the selectorCaptureFixture / makeIosAppSession fixtures — and restructures none of selector-capture-binding.ts, selector-capture-runtime.ts, or snapshot-runtime-binding.ts. Review after #1877.

This unit is not the last selector unit. find's cutover is deferred behind the Wave 5 focus/type surfaces, so createSelectorRuntime and its requireCommandSupported call survive with a 'find'-only union, captureData stays optional on captureSnapshot, and captureSnapshotWithInteractor stays.

is answers all seven predicates (visible, hidden, exists, editable, selected, focused, text) from the resolved capture — isCommand never reaches backend.readText — so its whole platform execution is the request-bound capture. It declares the shared selectorCaptureRuntimePlanUses, inspects exact owner facts once, refuses before binding, and binds exactly once. The use's preferred: ['readTextAtPoint'] member is get's; is binds it because the seam is shared and one binding serves the family, and never invokes it — so R37's singularExecution claims only the two capture operations, and readTextAtPoint stays R36's alone.

Admission moves ahead of the direct-iOS fast path

dispatchIsViaRuntime previously ran dispatchDirectIosSelectorIs before it built any runtime. Once is declares device-runtime, ADR 0019 §6 requires resolve → admit → bind before anything in the request path reaches the device, so admission now runs first for every target shape and that query is a fast path within an admitted request. This is the same blocker #1877 was held on (9056a4c79), taken by construction here rather than rediscovered in review.

The rule is now documented once, on createBoundSelectorRuntime, replacing the two duplicated six-line call-site comments get and is were each carrying. A future selector unit that adds a shortcut inherits it at the construction point.

queryDirectIosSelector itself is not claimed by this unit: it is co-owned by get, wait, and the Wave 5 offscreen-target probe, so under §6 it stays physically in place and remains named shared debt. This PR orders it; it does not migrate it.

Declared behaviour change: refusing beats displacing-and-lying

is takes the active-app plan split, so the facts decide per family rather than a hand-written iOS branch.

On iOS, appBundleId is the XCUITest attach target. With no tracked app the runner's own process comes to the foreground, displaces the app under test, and the capture then answers confidently about the runner's own three-node blank screen. Verified A/B on device this wave: with Settings foregrounded, a sessionless find "General" list returned success: true, matches: [] and left Settings displaced. On Android the same no-app state captures the real launcher correctly, and the platform facts already encode that asymmetry at their declaration sites — packages/platform-apple/src/runtime.ts:349 sets withoutActiveApp: isIosFamily(device) ? snapshotActiveAppRequired : capture, while packages/platform-android/src/runtime.ts:144 marks it available for every non-synthetic kind.

So an iOS is against a session with no tracked app is now a typed SESSION_NOT_FOUND refusal carrying the open hint, instead of a confident false answer about the runner's screen. This is a correctness fix, not a narrowing. Android, HarmonyOS, Linux, Web, and macOS proceed exactly as before.

Deliberately not claimed: the older rationale that such a capture "cannot succeed". That is too strong — capture works fine when SpringBoard is named as the session app. The real constraint is that XCUITest needs some bundle id, and the argument for refusing is that refusing beats displacing-and-lying.

Parity deltas (two, and nothing else)

Legacy denominator, computed not recalled: isCommandSupportedOnDevice('is', …) over the exhaustive 6-family × 6-AppleOS × 4-kind matrix (44 rows) admitted 19 cells — Apple simulator+device on ios/ipados/tvos/macos/visionos/watchos (12), Android device/emulator/unknown (3), HarmonyOS emulator/device (2), Linux device (1), Web device (1); Vega none.

  1. The two Apple watchOS cells were capability-supported with no snapshot backend, so they move from "admitted, then fails at the runner" to a typed unavailable admission refusal — the same classification snapshot (refactor: migrate snapshot to device runtime #1779), diff (refactor: migrate diff to request-bound runtime #1847), and get (refactor: migrate get to the request-bound device runtime #1877) already landed.
  2. The iOS-family no-tracked-app cells become the typed refusal above.

Every other cell keeps its outcome. Fact denominator after cutover — captureSnapshot: Apple simulator/device on ios/ipados/tvos/macos/visionos available, watchOS unavailable, other kinds unavailable; Android device/emulator/unknown available, synthetic simulator unavailable; HarmonyOS emulator/device available; Linux device; Web device; Vega unavailable everywhere. captureSnapshotWithoutActiveApp: unavailable for the whole Apple iOS family (ios/ipados/tvos/visionos/watchos), mirrors capture on macOS, otherwise as above. Providers — WebDriver available for both only when the owner advertises snapshot support, exposes an interactor, and the device is a mobile device-kind Android or ios Apple target (webDriverSnapshotDevice); Limrun available live, liveSessionUnavailable recovery-only. Provider ownership fails closed and never borrows a local family capture.

Retirement list

  • the is capability bucket (capability: ALL_DEVICE_COMMAND_CAPABILITY) on the descriptor;
  • capability admission for is — the capability: 'is' arm of createSelectorRuntime and the requireCommandSupported call it reached; the union narrows 'find' | 'is''find';
  • 'is' from HARMONYOS_SUPPORTED_COMMANDS and from WEB_QUERY_COMMANDS in src/core/capabilities.ts;
  • two redundant predicate as IsPredicate casts and a single-field split wrapper object in dispatchIsViaRuntimecheckIsArgs already returns predicate: IsPredicate, so both casts asserted what the type system had;
  • two duplicated six-line ADR 0019 comments at the get and is call sites, collapsed to one doc on createBoundSelectorRuntime;
  • the is entries in the legacy-matrix projection assertions (capabilities.test.ts macOS/Linux/web lists; the HarmonyOS static-capability list in capability-plugin-routing-parity.test.ts), following that file's own stated convention that a runtime-backed command's admission is proven from exact device facts, never through the legacy matrix projection;
  • 'is' and the already-dead 'get' from HARMONYOS_SUPPORTED_COMMANDS_REF, unreachable once the command leaves BASE_COMMAND_CAPABILITY_MATRIX (the (b.1) oracle iterates matrix keys);
  • a stale rule-id roster comment in scripts/layering/check.ts that stopped at R33 and already omitted viewport R34 and get R36;
  • createSelectorRuntime itself, with its requireCommandSupported call and its now-unused import. On the restacked base get moved read-only find's leg onto the bound seam, leaving is as its only caller — so migrating is made it fully unconsumed, and leaving it would have taken check:production-exports 19 → 20. This is the deletion the original wave plan assigned to is and that the mid-wave find deferral had suspended; get's read-leg move restored the condition. No selector command can now reach the device on a capability bucket, even by mistake.

Made live rather than retired: buildDirectIosIsResult's unreachable | null return and its caller's dead if (!payload) return null; guard, which #557 wrote for a fall-through it never armed.

is had no legacy adapter module and no dispatch projection of its own, so there is none to delete. ALL_DEVICE_COMMAND_CAPABILITY and LEGACY_PLATFORM_EXECUTION both keep other consumers.

Reversal of #557: a failing assertion now fails

Carried on thymikee's explicit instruction; my own recommendation had been to report it and leave it.

On the assigned iPhone 17, exercising the false paths surfaced this:

$ agent-device --session isfix is text id="com.apple.settings.primaryAppleAccount" "Wrong Expected Text"
Passed: is text
EXIT_CODE=0

is is an assertion. website/docs/docs/commands.md states the contract under Assertions: "is evaluates UI predicates against a selector expression and exits non-zero on failure." The direct-iOS arm broke it — {"success": true, "data": {"pass": false}} reaches isCliOutput, which renders Passed: is <predicate> without ever reading pass. A failing assertion reported as success is the one thing an assertion command must never do, and in a replay script it means execution continues past a broken state.

After:

$ agent-device --session isfix is text id="com.apple.settings.primaryAppleAccount" "Wrong Expected Text"
Error (COMMAND_FAILED): is text failed for selector id=…: expected="Wrong Expected Text" actual="Apple Account, Sign in to access your iCloud data, …"
EXIT_CODE=1

--json gives success: false, error.code: COMMAND_FAILED, details.reason: "predicate_failed", exit 1. The Passed: renderer needed no patch: once a negative can no longer produce a success envelope, it is correct by construction.

Which direction, and why it is not just "the older behaviour won". The two paths disagreed, and making them agree could have gone either way. The argument for the other direction is real — for an agent, "I asked a question and got an answer" beats "a false predicate is a command failure". I chose to make direct-iOS match the capture path because it is the documented contract rather than merely the incumbent one, and because the alternative is a different and much larger change: flipping is to a zero-exit predicate reporter would alter the contract on every platform and path, break every script and CI job that relies on is failing the shell, and require its own PR, docs, changelog, and probably a major version. It is also already the behaviour of is hidden (never handled by the fast path) and of is exists end to end. If the project does want the answer-shaped contract, that is a deliberate product change to make once, everywhere — not something to arrive at by leaving one iOS arm inconsistent.

#557's performance property is preserved, and separable. #557 bought a snapshot-free passing assertion on the replay hot path. That is untouched: a predicate that holds still answers from the runner query with zero captures, pinned by a direct-iOS predicate that holds still short-circuits without a capture. Only the negative falls through — which is what #557's own summary asked for, "preserving snapshot fallback for misses", refusing fallback only for hard failures like ambiguous matches. A predicate the one-node fast path could not establish is a miss, not a hard failure.

The fall-through was in fact #557's own design, never armed: buildDirectIosIsResult was already typed Record<string, unknown> | null behind a caller guard if (!payload) return null; that nothing could reach. This PR makes that dead guard live.

Measured cost (median of 9 runs each, warm daemon, iPhone 17):

Path Median Snapshots
Predicate holds — #557's hot path 0.14 s 0
Predicate fails — now falls through 0.25 s 1

So ~+0.11 s on failing assertions only, and nothing on the path #557 optimised. A replay that fails an assertion is a replay that is about to stop.

There is a correctness gain beyond the envelope: the fast path evaluates against a one-node tree (nodes: [node]), so visible cannot consult the ancestor geometry a list row inherits, and its negative can simply be wrong. Falling through re-asks the real captured tree, which can legitimately turn a spurious negative into a pass — pinned by a fast-path negative that the real tree contradicts resolves as a pass, not a failure.

The #557 pin itself moved with its reasoning at the pin site (src/daemon/handlers/__tests__/interaction.test.ts), renamed to is simple iOS selector falls through to the snapshot when the runner predicate fails, so a future reader finds out why the pinned value changed where it is pinned rather than only in this description.

Retirement of the direct-iOS is shortcut (thymikee's ruling)

A simple iOS id=/label= target was answered by a direct XCUITest querySelector with no capture — ordered after admission, but not executing through the seam. It is gone. Every predicate now resolves through the bound capture.

Not retired because it was wrong. wait hypothesized that the degenerate one-node evaluation mis-answers is visible for off-viewport nodes, traced it through the code convincingly, then tested it on device and it did not reproduce — XCUITest's own query is conservative about visibility, so the degenerate evaluation never gets the chance. It is retired because it was an undeclared, unmeasured bypass that made R37's singularExecution claim false: the same class of untruth as the sentinel retirement names fixed one commit earlier.

Declaring querySelector as a real operation instead was rejected concretely: offscreen-target-probe.ts consumes queryDirectIosSelector with a plain session and cannot take a bound operation, so declaring it now would ship it twice until Wave 5 moves the probe. It returns as a declared, fact-admitted, §9-measured operation in the unit that also moves the probe.

Retired: dispatchDirectIosSelectorIs, its call site, buildDirectIosIsResult, resolveDirectIosSelectorQuery — each with exactly one caller, all on this path — plus the ResolvedDirectIosSelectorQuery type they orphaned and two imports. queryDirectIosSelector stays: the offscreen probe consumes it and it remains single-copy. Net −104 lines in selector-runtime.ts.

Two costs, both declared, neither softened.

Latency. A held predicate on a simple iOS selector goes from ~0.14 s with no capture to ~0.25 s with one (median of 9 warm runs, iPhone 17). No fallback, no fast path.

is text now reads the same source as get text. This one is user-visible and was found on device, not predicted. The shortcut compared against the runner's querySelector text; the capture path compares against the resolved tree. On the same element they differ:

$ ad is text id="com.apple.settings.primaryAppleAccount" "Apple Account"
# before: Passed: is text
# after:  COMMAND_FAILED … actual="Apple Account, Sign in to access your iCloud data, …"

$ ad --json get text id="com.apple.settings.primaryAppleAccount"
{"text": "Apple Account, Sign in to access your iCloud data, …"}

So is text and get text disagreed about the identical selector, and now agree. A script asserting the runner's narrower value will start failing; the fix is to assert the value get text reports. Reported as a behaviour change rather than absorbed, because the tree text is what R37 declares is answers from.

Why the exit-code guarantee lives at the CLI layer

The retirement deletes buildDirectIosIsResult — the exact function the #557 reversal (commit 2) fixed. Commit 2's production change is therefore subsumed by this one, and its guarantee is strengthened rather than weakened: a failing assertion used to fail because of a guard inside the shortcut, and now fails structurally because the bound capture is the only thing that answers a predicate and isCommand raises COMMAND_FAILED.

That is exactly why the guarantee needed a test that does not live beside the daemon coverage. src/__tests__/cli-exit-paths.test.ts stubs a predicate_failed response and asserts the process exits 1 and never renders Passed — it does not know, and cannot know, which internal path decided. Its planted red is the pre-#1739 envelope the shortcut produced ({ok: true, data: {pass: false}}), which yields exitSpy.calls === []no exit call at all — the precise regression it exists to catch. The daemon half (is visible fails for nodes outside the current viewport) survives untouched at its own layer.

A reviewer seeing commit 2 fix a function commit 5 deletes should read them together: the decision was taken deliberately and is recorded, and the behaviour it established is now impossible to lose without this CLI case going red.

Seven tests lost their subject. Those whose only content was the shortcut's own mechanics are deleted; the outcome-level ones are retargeted and keep asserting what survives.

Validation

Red before green, captured by full revert rather than git checkout <base> -- <path> (which leaves newly added files on disk and contaminates the red), and re-proven after the comment relocation refactor. All six new route regressions failed against pre-change code:

 ❯ src/daemon/__tests__/is-runtime.test.ts (6 tests | 6 failed)
   × an admitted is inspects once, binds once, and answers through the bound capture
   × an unavailable capture fact refuses before any bind
   × an iOS session with no tracked app is refused with the open hint, not answered from a displaced capture
   × an iOS session WITH a tracked app still answers, so the refusal is the plan split and not an iOS ban
   × an Android session with no tracked app proceeds, because the owner advertises the without-active-app capture
   × the direct-iOS selector fast path cannot operate when facts refuse admission

AssertionError: expected 'COMMAND_FAILED' to be 'SESSION_NOT_FOUND'
Expected: "SESSION_NOT_FOUND"
Received: "COMMAND_FAILED"
 ❯ src/daemon/__tests__/is-runtime.test.ts:126:34

The two #557-reversal regressions were proved red separately, against the post-migration code:

 ❯ src/daemon/__tests__/is-runtime.test.ts (9 tests | 2 failed)
   × a direct-iOS predicate that does NOT hold falls through to the admitted capture instead of exiting zero
   × a fast-path negative that the real tree contradicts resolves as a pass, not a failure

AssertionError: expected true to be false        (response.ok)
AssertionError: expected 0 to be greater than 0  (fixture.captures.length)

The CLI change is proved on device, not only in JSON: exit code 01 for the same failing assertion (see the reversal section above).

Tests bind at inspectFacts / bindDevice, never at core/dispatch.ts. The nine existing is cases in interaction.test.ts already reached the runtime seam through getRuntimeBindings() and stay green unchanged.

Live device — iOS Simulator iPhone 17 (1604B975-D6CD-41D7-ABBD-BE95F0796F90), after pnpm build && pnpm clean:daemon, sessions closed afterwards (session list[]):

Case Result
is exists id=com.apple.settings.primaryAppleAccount Passed: is exists
is visible id=… Passed: is visible
is text id=… "Apple Account" Passed: is text
is hidden id=… (false path) COMMAND_FAILED: is hidden failed … actual={"visible":true,"editable":false,"selected":false,"focused":false}
is visible label="DefinitelyNotOnThisScreen" (not-found) COMMAND_FAILED: Selector did not match
is visible label="General" (ambiguous) AMBIGUOUS_MATCH: selector matched multiple elements
is visible on an iOS session with no tracked app SESSION_NOT_FOUND: iOS is requires an active app session on the target device. Run open first (for example: open --session isw4noapp --platform ios --device "<name>" <app>).

The refusal did not displace anything — the Settings session in the adjacent worktree session remained foregrounded and intact.

Android, HarmonyOS, Linux, Web, and every provider row are covered by fact/parity evidence only and are stated as residual live risk rather than claimed.

Gates. pnpm format:check, pnpm lint, pnpm typecheck (the full tsc -b package graph plus both project configs, not a narrower tsc -p tsconfig.json), pnpm check:layering (176/176 node tests plus the guard; the migrated-command list now reads … snapshot, diff, get, is, viewport), and pnpm check:affected --run. pnpm check:production-exports is 19, unchanged from the base and from main.

Accounting

Production source versus stack base 4778a2751, measured over git-tracked paths only so emitted .d.ts cannot contaminate the figure:

  • workspace-package TypeScript: 0 B — this unit adds no package code at all; the uses, plan resolver, seam, and fixtures all already existed;
  • root src/ TypeScript: +1,341 B, against a posted budget of "must decrease". It is entirely prose the reviewers asked for: +623 B relocating the admit-before-shortcut rule onto createBoundSelectorRuntime (replacing two duplicated call-site comments), and +1,196 B documenting the perf: speed up iOS replay runner #557 reversal at buildDirectIosIsResult and at the pin site, as instructed. Code-only movement is net negative — capabilities.ts −14 B, registry.ts −8 B, and selector-runtime.ts's own logic shrank before its doc block. thymikee accepted the size result on CI Size authority and directed me not to chase the byte figure; the operative bar from docs: keep size review in CI and local feedback fast #1842 is npm unpacked >3 kB or ~700 net production lines, and this is far under both.

Packaged versus stack base (all four negative, 313 files both sides):

  • raw JS −41 B (−0.0018%); gzip JS −33 B (−0.0043%); npm tarball −25 B; npm unpacked −50 B (tarball/unpacked from the artifact-consistent measurement — see the caveat below).

Both sides were measured with the built helper artifacts present; a later re-measurement against a fresh build without them produced meaningless tarball/unpacked rows, which is the documented local caveat. The CI Size workflow is authoritativedocs/agents/adr-0019-unit.md on current main (after #1842) says not to run a local size comparison by default. My stack base predates #1842, whose adr-0019-unit.md still prescribes pnpm size --compare against the base build; that cost nothing here because the base build was needed for device verification anyway.

Duplication unchanged (fallow audit: dead code 0, duplication 0, one pre-existing complexity warning on a test arrow in interaction.test.ts). Daemon-server → platforms unchanged at 62 total / 43 value edges, zero value cycles, zero spine back-edges.

Tradeoffs and known gaps

  • No ADR 0011 matrix row, per the ruling on refactor(daemon): platform-free daemon — broader migration waves (successor to #1696) #1739. Verified again at the declaration site: packages/contracts/src/interaction-guarantees.ts classifies zero observation commands and its guarantee vocabulary is entirely tap semantics, so an is row would be all-inapplicable — a row that satisfies the completeness gate while asserting nothing. Carried at the descriptor level instead.
  • R37's retirement claim was fictional and is now real (review blocker, fixed in 05e6d8830). It claimed routeNames: ['WEB_QUERY_COMMANDS_WITH_IS', 'HARMONYOS_IS_SUPPORT'] — identifiers that have never existed, satisfying the non-empty shape check while proving nothing. The cause was the model: every LegacyRetirementClaim form names something that must NOT exist, which a row can always satisfy by inventing a name. is retired no module, route, or dispatch projection because it had none, so its real retirement is a data deletion the model could not express. Rather than sentinels or a per-command policy file (both forbidden), the model gained staticCommandSets, proven from both sides: each named set must still be declared in production source and must no longer list the command. Planted red against the real gate — the exact claim that shipped is now rejected by name, and so is restoring the membership it claims to have removed. Layering 177 → 181 tests.
  • is has no @ref target form. Its only target is a selector expression (checkIsArgssplitIsSelectorArgs); is visible @e23 is INVALID_ARGS: is requires a selector expression. Recorded because the unit brief assumed otherwise and my start record's evidence list over-claimed "selector and @ref targeting".
  • The reversal of perf: speed up iOS replay runner #557 is described in its own section above rather than buried here.
  • Docs and skills/** are unchanged: CLI grammar, help, and user-visible output are unchanged apart from the declared iOS no-app refusal, which is an error path with its own typed hint rather than a documented surface.

Touched files, this unit's own commit dd6c8f531: 10 — 3 production src/, 2 scripts/layering/, 4 existing test files, 1 new test file (+306 / -35). GitHub's diff against main shows 63 files / +2246 / -475 because the branch is stacked: it carries #1876 and #1877 underneath. Review this unit's commit, or diff against 4778a2751.

Scope did not expand beyond the unit; the only adjacent edits are the tightening pass listed above.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.32 MB 2.32 MB -238 B
JS gzip 763.7 kB 763.6 kB -66 B
npm tarball 887.0 kB 886.9 kB -85 B
npm unpacked 3.09 MB 3.09 MB -238 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 24.9 ms 20.4 ms -4.4 ms
CLI --help 60.4 ms 51.5 ms -9.0 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/dispatch.js -10 B -4 B
dist/src/sdk-batch-runner.js +17 B -3 B

@thymikee
thymikee changed the base branch from main to agent/wave4-get August 19, 2026 13:56
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed unit head dd6c8f531 against its declared stack base 4778a2751. Three blockers:

  1. [P1] Keep is on its bound runtime path. createBoundSelectorRuntime authorizes and binds only the capture use, but the following direct-iOS branch calls queryDirectIosSelector → raw runAppleRunnerCommand and can return without invoking the bound capture; the new test explicitly proves that. Admission ordering protects unavailable/no-app cases, but does not make this a single request-bound execution path. Remove the shortcut until it is runtime-owned, or model it as a measured, fact-backed preferred operation invoked through the binding. Also assert the no-tracked-app refusal never invokes the runner.

  2. [P2] Make R37’s retirement claim truthful. legacyRetirement.routeNames names invented identifiers (WEB_QUERY_COMMANDS_WITH_IS, HARMONYOS_IS_SUPPORT) solely because the table requires a non-empty retirement form. The paragraph-long defense is evidence the model is wrong. Generalize the cutover model so its automatic admission-retirement evidence is expressible; do not satisfy an invariant with fictional sentinels.

  3. Size/dependency: authoritative CI reports +4.7 kB npm unpacked, above the scrutiny threshold; reconcile and itemize that growth rather than substituting local stack-base accounting. This PR is also stacked on blocked refactor: migrate get to the request-bound device runtime #1877. Linux Smoke was cancelled before test execution and needs a real run after the code/dependency changes.

agent added 6 commits August 19, 2026 17:01
Behaviour-neutral. No descriptor changes platform execution, the cutover table
is untouched, and no contract surface is added.

- 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.
- 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.
Read-only `find` now constructs a BOUND selector backend, so `get text` and
`find <q> get text` execute the same bound `readTextAtPoint` instead of one
binding it and the other dispatching the legacy `read`. This moves find's READ
LEG only: find's descriptor stays LEGACY_PLATFORM_EXECUTION and it claims no
cutover row.

With no consumer left, the whole chain goes: the `read` registry entry and its
`dispatch: {}` projection, `DISPATCH_HANDLERS.read`, `handleReadCommand`,
`interaction-read-legacy-dispatch.ts`, and the duplicate platform reader
branches it carried. `read` was the only `dispatch-alias` descriptor, so that
catalog group goes too.

Deleting the registry entry drops 'read' from DescriptorDispatchCommandName,
which makes a surviving DISPATCH_HANDLERS.read a compile error rather than
something R36 has to police. R36 now claims the retirement it can prove.

`find.test.ts` is over the size tripwire, so its handler invocation is
extracted to find-handler-fixture.ts and the pin lowered 1237 -> 1221.
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.
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 37d0b9c4. The new negative fast-path behavior is sound and CI Size is now clean (−41 B unpacked), but two architecture blockers remain:

  1. is is not wholly runtime-backed. dispatchIsViaRuntime binds the capture runtime, then bypasses it through dispatchDirectIosSelectorIsqueryDirectIosSelector → raw runAppleRunnerCommand; R37 declares no query operation. Admission-before-shortcut does not make this a bound operation. Move querySelector behind a declared, fact-admitted runtime operation and execute through it, or remove the is shortcut until the shared owner provides that interface. The paragraph-long justification for retaining the raw path is evidence the seam is wrong, not a substitute for it.

  2. R37’s retirement claim is fictional. WEB_QUERY_COMMANDS_WITH_IS and HARMONYOS_IS_SUPPORT have never existed; they satisfy a non-empty shape check without proving a deletion. Express the actual data-only removal from the static command sets, or repair the owning gate so that retirement is represented honestly.

The branch is also stacked on currently DIRTY #1877; Linux and iOS Smoke are still running.

agent added 3 commits August 19, 2026 17:37
`is` declares the shared selector capture use, 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 `is` are gone; `'is'` leaves the
`createSelectorRuntime` capability union.

Admission now runs BEFORE the direct-iOS selector fast path. ADR 0019 requires
resolve -> admit -> bind before anything in a `device-runtime` command's request
path reaches the device, so that query becomes a fast path *within* an admitted
request rather than a way around exact-owner facts. The rule is documented once,
on `createBoundSelectorRuntime`, replacing the two duplicated call-site comments
`get` and `is` were each carrying.

Declared behaviour change: `is` takes the active-app plan split, so the facts
decide per family. On iOS `appBundleId` is the XCUITest attach target — with no
tracked app the runner's own process comes to the foreground, displaces the app
under test, and the capture then answers confidently about the runner's own
blank screen. An iOS `is` on a session with no tracked app is now a typed
SESSION_NOT_FOUND refusal carrying the `open` hint. Refusing beats
displacing-and-lying. Android captures the real launcher in that state and is
unchanged, which is what the platform facts already encoded.

The two Apple watchOS cells move from capability-admitted-then-runner-failure to
a typed unavailable refusal, the same classification snapshot, diff, and get
already landed.

R37 is the new parametrized cutover row. `find` keeps `createSelectorRuntime`
and its `requireCommandSupported` call, so `captureData` stays optional and
`captureSnapshotWithInteractor` stays: this unit is not the last selector unit.
Reverses part of #557, on thymikee's explicit instruction.

`is` is an assertion: the docs state it "exits non-zero on failure". The
direct-iOS fast path broke that contract — it reported a failed predicate as a
completed command, so on device

    $ agent-device is text id=… "Wrong Expected Text"
    Passed: is text          (exit 0)

because `{ok: true, pass: false}` reaches `isCliOutput`, which renders
"Passed: is <predicate>" without reading `pass`. A failing assertion reported as
success lets a replay run on past a broken state. Now:

    Error (COMMAND_FAILED): is text failed for selector id=…:
      expected="Wrong Expected Text" actual="Apple Account, …"   (exit 1)

The renderer needed no patch: a negative can no longer produce a success
envelope, so it is correct by construction.

Direction chosen deliberately. Making the two paths agree could have gone either
way, and "an agent asked a question and got an answer" is a real argument for the
other one. This follows the DOCUMENTED contract rather than merely the incumbent
behaviour, and the alternative is a far larger change: a zero-exit `is` would
alter every platform and path, break scripts that rely on it failing the shell,
and needs its own PR, docs, and probably a major version. It is also already how
`is hidden` and `is exists` behave end to end.

#557's perf property is preserved and separable: it bought a snapshot-free
PASSING assertion, and that arm still answers with zero captures (pinned). Only
the negative falls through — what #557's own summary asked for, "preserving
snapshot fallback for misses", refusing fallback only for hard failures like
ambiguity. The fall-through was #557's own design, never armed: the `| null`
return and the caller's `if (!payload) return null;` guard were unreachable.
This makes that dead guard live.

Measured on iPhone 17 (median of 9, warm daemon): predicate holds 0.14s / 0
snapshots, unchanged; predicate fails 0.25s / 1 snapshot. ~+0.11s on failing
assertions only.

Correctness gain beyond the envelope: the fast path evaluates a ONE-NODE tree, so
`visible` cannot see the ancestor geometry a list row inherits and its negative
can be wrong. Falling through re-asks the real tree and can turn a spurious
negative into a pass.

The #557 pin moved with its reasoning at the pin site.
Review blocker on #1883: R37 claimed `legacyRetirement.routeNames:
['WEB_QUERY_COMMANDS_WITH_IS', 'HARMONYOS_IS_SUPPORT']`. Neither identifier has
ever existed. They satisfied the non-empty shape check while proving nothing —
the vacuous registry claim AGENTS.md warns about, and a green gate that would
stay green if the deletion were reverted.

The cause was the model, not the row. Every `LegacyRetirementClaim` form names
something that must NOT exist, which a row can always satisfy by inventing a
name. `is` retired no module, route, or dispatch projection because it had none:
its legacy admission was a capability bucket plus membership in two static
platform command sets, so its real retirement is a DATA deletion the model could
not express. Rather than patch around that with sentinels or a per-command
policy file — both forbidden by the playbook — this generalizes the model.

`staticCommandSets` names the sets themselves and is proven from both sides:
each must still be DECLARED in production source, and must no longer list the
command. A fictional set fails the first half; a skipped deletion fails the
second. That is what an identifier-shaped claim cannot state.

R37 now claims HARMONYOS_SUPPORTED_COMMANDS and WEB_QUERY_COMMANDS, which is the
deletion it actually performed.

Planted red, both halves, against the real gate:

  [R37 is-runtime-cutover] 2 violation(s):
    (is cutover row):1 — claims retired static command set
      'WEB_QUERY_COMMANDS_WITH_IS', which no production source declares
    (is cutover row):1 — claims retired static command set
      'HARMONYOS_IS_SUPPORT', which no production source declares

  [R37 is-runtime-cutover] 2 violation(s):
    src/core/capabilities.ts:59 — static command set WEB_QUERY_COMMANDS still
      admits is

so the exact claim that shipped is now rejected by name, and so is restoring the
membership it claims to have removed. Mechanism cases live with the other
planted-row tests; layering goes 177 -> 181.
thymikee pushed a commit that referenced this pull request Aug 19, 2026
`get` declares device-runtime, so its request path must reach the platform only
through operations R36 declares. `dispatchDirectIosSelectorGet` reached
`runAppleRunnerCommand` through a path the row declares no operation for;
admitting before a bypass is not executing through the seam, so the bypass is
removed rather than ordered after admission. Every target shape — including the
simple iOS `id=` selector — now resolves through the bound capture.

`queryDirectIosSelector` itself stays: `offscreen-target-probe.ts` still
consumes it and it remains single-copy. `dispatchDirectIosSelectorIs` belongs to
`is` (#1883). Two get-only helpers (`readDirectIosGetSelector`,
`buildDirectIosGetResult`) became unreachable and are deleted with the caller.

Declaring `querySelector` as a fact-admitted preferred operation was rejected on
duplication, not correctness: the offscreen probe takes a plain session and
cannot consume a bound operation, so it would ship the query twice until Wave 5
moves the probe — the deferred-duplication shape this PR was already overruled
for on the `read` alias. It returns as a declared, §9-measured operation in a
later unit that also moves the probe.

Cost, stated plainly: `get text id=…` loses its tree-capture skip on iOS. No
fallback was added and the latency is not recovered elsewhere. R36's
singularExecution claim is now what the code does rather than aspirational.
@thymikee

Copy link
Copy Markdown
Member Author

Blocking on exact 05e6d883: R37’s static-set retirement check is now real, but the successful direct-iOS is path still executes dispatchDirectIosSelectorIs / the raw runner after binding, outside the selected runtime operations. Admission ordering is not enough when the bound projection is unused; the paragraph explaining why this exception is acceptable is evidence the invariant is not obvious in code. Model the native selector query as an owner-provided preferred operation and call it through the binding, or remove the shortcut, with a regression proving the success path uses the admitted owner. This draft is also DIRTY against current #1877 52a1c9d and the body names obsolete base 4778a; restack, refresh evidence, and rerun CI. No ready-for-human.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant