Skip to content

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

Merged
thymikee merged 5 commits into
agent/wave4-getfrom
agent/wave4-is
Aug 20, 2026
Merged

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

Conversation

@thymikee

@thymikee thymikee commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Migrates is from legacy capability admission to the request-bound selector capture runtime, stacked on #1877.

The route now resolves the owner plan, inspects facts once, refuses before binding, and uses the bound capture operation. Provider ownership remains authoritative and fails closed.

Review tightening is complete:

  • The successful direct-iOS is shortcut was removed. dispatchDirectIosSelectorIs, buildDirectIosIsResult, and its raw runner success path are no longer reachable.
  • is text now reads the resolved canonical tree, matching get text.
  • A false iOS assertion exits nonzero as documented instead of returning success.
  • R37 records the real data-only retirement from the Web and HarmonyOS static command sets. The cutover model now verifies both that each named set exists and that is is absent; fictional sentinel names are rejected.
  • No capability or legacy fallback remains.

Deliberate behavior change: assertions that depended on the narrower runner-only text value may now fail. Use agent-device get text <selector> to inspect the canonical value.

Validation

Exact head: 4d36f690594a903ca5a7d2cbb26df12e39bdbced
Base: agent/wave4-get at 865145e1c3d4ba23635ef474d6b3c31027009d2c

  • pnpm check:affected --run && git push: pass; 5,122 tests
  • Changed-line coverage: 78/81 (96.30%)
  • Provider integration, layering, package, wire compatibility, smoke, and quality gates passed locally.
  • The tmpdir lifecycle model had one external timing race in the first run, passed immediately in isolation, and the complete authoritative rerun passed unchanged.
  • Exact-head size is net-negative: −1.1 kB raw JS, −298 B gzip JS, −300 B tarball, and −1.1 kB unpacked.
  • GitHub: every non-iOS check is green. iOS Smoke attempt 2 is running after attempt 1 hit an unrelated hardware-keyboard XCTest partial entry (h vs hardware-keyboard).

Docs and skills are unchanged because CLI grammar, flags, help, and output schema are unchanged; the declared text-source and exit-code corrections are covered by tests.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.33 MB 2.33 MB -1.1 kB
JS gzip 766.5 kB 766.2 kB -298 B
npm tarball 891.1 kB 890.8 kB -300 B
npm unpacked 3.11 MB 3.10 MB -1.1 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.8 ms 29.1 ms +1.4 ms
CLI --help 70.2 ms 72.1 ms +1.9 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/sdk-batch-runner.js +17 B +4 B
dist/src/dispatch.js -10 B -4 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.

@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.

thymikee pushed a commit that referenced this pull request Aug 19, 2026
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.

thymikee pushed a commit that referenced this pull request Aug 20, 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 pushed a commit that referenced this pull request Aug 20, 2026
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
thymikee marked this pull request as ready for review August 20, 2026 06:38
@thymikee

Copy link
Copy Markdown
Member Author

Out of draft at 397aa71777dd8f10ef94cd4f3491bf1a6dd4792fall 23 exact-head checks green, including the lanes that previously had no verdict.

Your three blockers are resolved by removal

All three reviews named the same thing in escalating terms: dispatchIsViaRuntime binding the capture and then bypassing it through dispatchDirectIosSelectorIsqueryDirectIosSelector → raw runAppleRunnerCommand, outside R37's declared operations. Verified at this head:

symbol refs in selector-runtime.ts
dispatchDirectIosSelectorIs 0
buildDirectIosIsResult 0
resolveDirectIosSelectorQuery 0
queryDirectIosSelector 4 — survives, single-copy

That is the "or remove the shortcut" half of the remedy you offered each time. queryDirectIosSelector stays for offscreen-target-probe.ts, which takes a plain session and cannot consume a bound operation — declaring it as a runtime operation would ship it twice until Wave 5 moves the probe, which is the deferred-duplication shape #1877's read deferral was overruled for. Recorded as a tracker ruling.

R37's fictional retirement claim is fixed by generalizing the model, not by patching the row. WEB_QUERY_COMMANDS_WITH_IS and HARMONYOS_IS_SUPPORT never existed. legacyRetirement now has staticCommandSets, which is two-sided and cannot be faked: each named set must still be declared in production source and must no longer list the command. Planted red both ways — the exact fiction that shipped is rejected by name, and so is restoring the membership.

Your "the paragraph-long defense is evidence the model is wrong" read was correct, and it was the third time in this wave that signal located a real defect.

Two behaviour changes, declared not absorbed

  • is text now reads the resolved tree, the same source get text uses. The two commands disagreed about the same element and now agree. The PR body carries a [!WARNING] with the remedy: run agent-device get text <selector> and assert that string. Assertions written against the runner's narrower value will start failing.
  • A failing iOS assertion now fails instead of exiting zero. Argued from commands.md's Assertions section (is "exits non-zero on failure"), so the fast path was violating the documented contract — and it arms a guard perf: speed up iOS replay runner #557 had already designed but left unreachable (buildDirectIosIsResult was typed | null behind a caller check nothing could reach). Cost measured: 0.14 s → 0.25 s on failing assertions only.

Evidence

  • Linux Smoke: SUCCESS — previously cancelled before execution, never gave a verdict. It has a real one now.
  • Device evidence carried forward from aa12c9ff4 rather than re-run, justified by diffing snapshot fact cells across all six platform packages and both providers: zero changes to captureSnapshot, captureSnapshotWithoutActiveApp, or snapshotRuntimeOperationFacts — R37's exact denominator. get's delta is the element-read mechanism and the derived facts twin, neither on is's path.
  • Cutover table parses to 22 rows / 22 rules / 22 commands, no defects.
  • Production diff is net −1 (+135/−136): selector-runtime.ts +14/−101, selector-runtime-backend.ts +12/−28, with the retirement adding a further −104.

One caveat on the Size numbers: the size-report comment on this PR was last updated at 06:19Z, which predates this head. The Size check is green, but that comment's table describes an earlier commit — I am not quoting it as current. Given the retirement removed ~104 net lines since, the real delta should be more negative, not less.

Base is agent/wave4-get so the diff shows only this unit's 16 files; it retargets to main when #1877 merges.

@thymikee

Copy link
Copy Markdown
Member Author

Correction to my previous comment: the Size numbers on this PR are current, not stale. That caveat was my error.

I compared the size-report comment's updated_at (06:19:44Z) against wall-clock and concluded it predated this head. That test is wrong and produces false staleness. The correct test is whether the Size run that wrote the comment has head_sha equal to the PR head, and whether the comment update falls inside that run's job window.

Checked properly:

value
PR head 397aa71777dd8f10ef94cd4f3491bf1a6dd4792f
Size run head_sha 397aa71777dd8f10ef94cd4f3491bf1a6dd4792f
Size job window 06:18:54 – 06:19:50Z
comment updated_at 06:19:44Z

The comment was written by this head's own Size run, six seconds before that job completed. It describes exactly this commit. The same holds for #1875 and #1877 — all three size comments were verified current by matching run head_sha to PR head.

So the size figures reported on this PR stand as authoritative per #1842, and the earlier "not quoting it as current" caveat should be disregarded. Nothing else in that comment changes.

Credit where due: this was caught by the wait unit checking my reasoning rather than accepting the caveat I had propagated to it.

thymikee pushed a commit that referenced this pull request Aug 20, 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.
agent added 5 commits August 20, 2026 11:53
`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.

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.
…he predicate

Prep for the Blocker 1 retirement, which deletes `buildDirectIosIsResult` — the
function the #557 reversal fixed. The reversal's guarantee must not evaporate
with it, so it gets a case that does not know how the daemon decided.

`is` is documented to "exit non-zero on failure". The reversal proved that at
the JSON envelope; nothing pinned it at the CLI boundary, which is where the
defect was actually visible (`Passed: is text`, exit 0). This asserts the CLI
contract directly: a `predicate_failed` response exits 1 and never renders as
passed.

It survives the retirement untouched, because it asserts the outcome rather than
the path. Planted red with the exact pre-#1739 envelope the shortcut produced
(`{ok: true, data: {pass: false}}`): `exitSpy.calls` is `[]` — no exit call at
all — so the case fails, which is the regression it exists to catch.

Unpushed on purpose: the restack will carry it into the retirement cycle.
thymikee's ruling (option b). `is` declares `device-runtime`, so its request path
must reach the device only through the operations R37 declares. It did not: a
simple iOS `id=`/`label=` target was answered by a direct XCUITest querySelector
without any capture, ordered after admission but not executing through the seam.

This is 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 in the previous commit.

Declaring querySelector as a real operation instead was rejected for a concrete
reason: 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 — the deferred-duplication shape that got
get's read deferral overruled. It returns as a declared, fact-admitted,
section 9-measured operation in the unit that also moves the probe.

Retired: dispatchDirectIosSelectorIs, its call site, buildDirectIosIsResult, and
resolveDirectIosSelectorQuery — each had exactly one caller, all on this path —
plus the ResolvedDirectIosSelectorQuery type they orphaned and two imports.
queryDirectIosSelector itself stays: the offscreen probe still consumes it and it
remains single-copy.

Latency cost, stated plainly and not softened: a held predicate on a simple iOS
selector goes from ~0.14s with no capture to ~0.25s with one, measured as the
median of 9 warm runs on iPhone 17. There is no fallback and no fast path.

R37's comment finally describes the code: "every predicate answers from the
resolved tree" was written while the shortcut existed. Its scope is now stated
too, so it is not read as absolute — the Android foreground-blocker diagnostic
still reaches adb on the failure path, where it cannot produce or change a
verdict; that edge is pre-existing, co-owned with wait, and recorded as Wave 6
denominator work with R22's appState as its declared replacement.

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.
@thymikee

Copy link
Copy Markdown
Member Author

Rebased and revalidated on exact head 4d36f690594a903ca5a7d2cbb26df12e39bdbced over exact #1877 head 865145e1c3d4ba23635ef474d6b3c31027009d2c.

The architecture blockers remain resolved by deletion: the successful direct-iOS is shortcut and its raw runner path have zero route references, so is executes through the admitted bound capture runtime. R37 uses the two-sided staticCommandSets check; invented set names and restored membership both fail the owning gate.

The chained pnpm check:affected --run && git push passed unchanged: 5,122 tests and 96.30% changed-line coverage. The first attempt hit a tmpdir lifecycle timing race, the exact model passed in isolation, and the full authoritative rerun passed. GitHub required/device lanes are now running on this exact head. The description has been refreshed to remove obsolete bases and size claims.

@thymikee

Copy link
Copy Markdown
Member Author

CI handoff: every non-iOS check is green. iOS Smoke attempt 1 failed in the untouched targeted runner test testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden, which entered only h instead of hardware-keyboard; the query-selector XCTest passed. Failed-job-only attempt 2 is currently running. Per request, I am not waiting for its verdict.

@thymikee
thymikee merged commit 58a21e9 into agent/wave4-get Aug 20, 2026
23 of 24 checks passed
@thymikee
thymikee deleted the agent/wave4-is branch August 20, 2026 11:12
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-20 11:12 UTC

thymikee pushed a commit that referenced this pull request Aug 20, 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 added a commit that referenced this pull request Aug 20, 2026
* refactor: migrate is to the request-bound device runtime

`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.

* fix(is): a failing iOS assertion fails instead of exiting zero

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.

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.

* fix(layering): let a cutover row state a data-only admission retirement

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.

* test(is): pin the exit-code guarantee independently of what answers the predicate

Prep for the Blocker 1 retirement, which deletes `buildDirectIosIsResult` — the
function the #557 reversal fixed. The reversal's guarantee must not evaporate
with it, so it gets a case that does not know how the daemon decided.

`is` is documented to "exit non-zero on failure". The reversal proved that at
the JSON envelope; nothing pinned it at the CLI boundary, which is where the
defect was actually visible (`Passed: is text`, exit 0). This asserts the CLI
contract directly: a `predicate_failed` response exits 1 and never renders as
passed.

It survives the retirement untouched, because it asserts the outcome rather than
the path. Planted red with the exact pre-#1739 envelope the shortcut produced
(`{ok: true, data: {pass: false}}`): `exitSpy.calls` is `[]` — no exit call at
all — so the case fails, which is the regression it exists to catch.

Unpushed on purpose: the restack will carry it into the retirement cycle.

* refactor(is): retire the direct-iOS selector shortcut

thymikee's ruling (option b). `is` declares `device-runtime`, so its request path
must reach the device only through the operations R37 declares. It did not: a
simple iOS `id=`/`label=` target was answered by a direct XCUITest querySelector
without any capture, ordered after admission but not executing through the seam.

This is 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 in the previous commit.

Declaring querySelector as a real operation instead was rejected for a concrete
reason: 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 — the deferred-duplication shape that got
get's read deferral overruled. It returns as a declared, fact-admitted,
section 9-measured operation in the unit that also moves the probe.

Retired: dispatchDirectIosSelectorIs, its call site, buildDirectIosIsResult, and
resolveDirectIosSelectorQuery — each had exactly one caller, all on this path —
plus the ResolvedDirectIosSelectorQuery type they orphaned and two imports.
queryDirectIosSelector itself stays: the offscreen probe still consumes it and it
remains single-copy.

Latency cost, stated plainly and not softened: a held predicate on a simple iOS
selector goes from ~0.14s with no capture to ~0.25s with one, measured as the
median of 9 warm runs on iPhone 17. There is no fallback and no fast path.

R37's comment finally describes the code: "every predicate answers from the
resolved tree" was written while the shortcut existed. Its scope is now stated
too, so it is not read as absolute — the Android foreground-blocker diagnostic
still reaches adb on the failure path, where it cannot produce or change a
verdict; that edge is pre-existing, co-owned with wait, and recorded as Wave 6
denominator work with R22's appState as its declared replacement.

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.

---------

Co-authored-by: agent <agent@local>
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