Skip to content

refactor: migrate get to the request-bound device runtime - #1877

Open
thymikee wants to merge 6 commits into
mainfrom
agent/wave4-get
Open

refactor: migrate get to the request-bound device runtime#1877
thymikee wants to merge 6 commits into
mainfrom
agent/wave4-get

Conversation

@thymikee

@thymikee thymikee commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Migrates the get descriptor off platformExecution: LEGACY_PLATFORM_EXECUTION onto a
request-bound device runtime (ADR 0019 §6), Wave 4 unit 2 of issue #1739. Cutover rule id R36.

Start record: #1739 (comment)
Read-alias classification correction: #1739 (comment)

get now declares elementReadRuntimeUse — required captureSnapshot, preferred
readTextAtPoint — resolves its plan, performs one side-effect-free facts inspection, refuses
before binding when the required capture is unavailable, and binds exactly once on the admitted
device through the admitRuntimePlan token.

The new neutral readTextAtPoint operation replaces the branch-per-family legacy read dispatch
on the get path. Every local family and both providers classify it exhaustively; the
typechecker forced that completeness (the required fact is a missing-property error until each
owner declares it).

Read before reviewing: two things this PR does not claim

  1. It is based on main (6984a1e09), not on find's seam branch. find published
    agent/wave4-find after this work was already green, and its shape is better than mine: it
    adds createBoundSelectorRuntime(params, { command }) plus resolveBoundSelectorCapture, and
    builds the per-capture CaptureSnapshotInput in selector-capture-runtime.ts. This branch
    must be rebased onto find before it is anything but a draft.
    The exact reconciliation is in
    "Rebasing onto find" below — it deletes code from this PR rather than adding any.
  2. It does not retire the read dispatch alias, and that leaves a real duplication this PR
    should not be merged with. See "Known gaps", which is the most important section here.

Cutover and retirement

Retired from the live path:

  • the get capability bucket on the descriptor;
  • requireCommandSupported admission reached with capability: 'get''get' leaves the
    createSelectorRuntime(..., { capability }) union, which now reads 'find' | 'is'. The option
    and the call itself stay for the is unit to delete last, per the batch plan;
  • 'get' from HARMONYOS_SUPPORTED_COMMANDS and WEB_QUERY_COMMANDS in src/core/capabilities.ts
    (addWebCommandCapabilities throws for a web-listed command with no matrix row, so the web entry
    could not have been left behind);
  • get's stale capability assertions in the descriptor parity and plugin-routing parity suites;
  • the vi.mock('.../core/dispatch.ts') binding in get's own tests — moved to the
    inspectFacts / bindDevice seam in this PR.

readTextForNode is now platform-free: it owns only the policy of when to consult a live read,
and the read itself is injected.

Declared parity delta

The two Apple watchOS cells are legacy-capability-supported for get but have no snapshot
backend, so they move from "admitted, then fails at the runner" to a typed unavailable admission
refusal. This is the same watchOS sentinel snapshot (#1779) and diff (#1847) already landed,
not a new behavior decision, and it is the only cell where the fact denominator differs from the
19-cell capability denominator computed in the start record.

Preferred-operation measurement (ADR 0019 §9)

Recorded live on iOS Simulator iPhone 17 Pro (F7D6F9A4-4FCC-4DD7-AC0B-3280C9319CB9), Settings
search field with a 1,127-character value:

chars returned
required path alone (captured tree, via get attrs value) 511 (snapshot marks it [truncated])
with the bound preferred readTextAtPoint 1,126

The preferred read recovers 615 characters (2.2x) the required path truncates. Cost, measured
over 3 runs each: get text on an editable node (live read fires) 0.46–0.48 s versus a
non-editable node (tree only) 0.07–0.09 s, so the read costs ~0.38 s and is gated on iOS to
editable/expandable nodes only — the pre-existing gate, preserved verbatim.

Validation

Red before green

R36 was added to the cutover table and run against a clean pre-cutover tree (base src/ and
packages/, new modules removed) — 5 violations:

[R36 get-runtime-cutover] 5 violation(s):
  src/core/capabilities.ts:39 — static platform command set retains get admission
  src/core/capabilities.ts:61 — static platform command set retains get admission
  src/core/command-descriptor/registry.ts:1180 — get descriptor retains legacy capability admission
  (get runtime):1 — expected one narrowed captureSnapshot call, found 0
  (get runtime):1 — expected one narrowed readTextAtPoint call, found 0

After the cutover: pnpm check:layering green, and get joins the migrated-command list —
"each migrated command (… snapshot, diff, get, viewport) keeps exactly one platform-execution path".

My first red capture was contaminated and I am recording that rather than quietly re-running
it: git checkout <base> -- src packages leaves newly-added files on disk, so get-runtime.ts
was still present and the two operation-count violations were masked (4 violations, then 3). The
5-violation result above is from a tree with the new modules actually removed.

Descriptor/capability red, captured against pre-change code:

get.platformExecution = {"kind":"legacy"}
get has capability bucket = true {"apple":{...},"android":{...},"linux":{...}}
read command still projects into the retired legacy dispatcher   (registry.ts:1201)

Note the union narrowing is not covered by the gate's admission column:
requireCommandSupported(options.capability, device) passes a variable, not a literal, so the
gate cannot see it. The type-level narrowing of the capability union is the proof instead, and
the is unit deletes the call.

Gates

  • pnpm check:layering: PASS (1,324 source files; R36 green).
  • Full vitest run --project unit-core behind the shared wave-4 gate lock: 921 files / 6,964
    tests, all passing
    .
  • Typecheck (tsc -b over all 17 workspace packages, then root): PASS.
  • pnpm format: applied.
  • Clean committed-tree pnpm check:affected --run && git push: PASS — "all runnable checks
    passed", pushed at 28b327ff0f36128f1024d5af752b3a2267c80979. Two earlier attempts failed the
    gate (format, then fallow) and correctly did not push; both causes are fixed in this head.
  • fallow audit: clean on 62 changed files. The audit initially flagged two unused fixture exports
    and one high-complexity function in platform-runtime-element-text-host.ts — both fixed by the
    tightening pass (mocks made module-private; the four-branch dispatcher split into named
    per-family readers).

New coverage

  • src/daemon/__tests__/get-runtime.test.ts (9 tests): no-session refusal before any inspection or
    bind; exactly one inspection and one bind; the admitted device and exact use reaching bindDevice;
    required-capture-unavailable refusing before binding; preferred-unavailable still admitting
    and binding; provider fail-closed; provider-with-capture-but-no-read; missing-gateway refusal.
  • src/daemon/handlers/__tests__/interaction-read.test.ts — rewritten onto the runtime seam, plus
    new rows for preferred-absent, typed-reason failure fallback, and blank-read fallback.
  • src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts — the shared inspectFacts /
    bindDevice fake for interaction tests.
  • Exhaustive readTextAtPoint fact assertions in all six platform packages and both providers.
  • Live iOS evidence: get text / get attrs over @ref and selector, both not-found failure
    paths, 3 readText runner commands proving the bound preferred operation reaches the platform,
    and zero platform_command_prepare diagnostics with command: "read" proving the legacy
    dispatch never fires for get. Session opened and closed.

Test fixture change that is not incidental churn

makeSession in src/daemon/handlers/__tests__/interaction-touch-fixtures.ts now returns
makeIosAppSession instead of makeIosSession. This is a behavior surface, not tidying.

On an iOS-family leaf the without-active-app capture row is unavailable. A selector command run
against a session with no tracked app therefore resolves the without-active-app plan, is refused
at admission before it ever captures
, and returns the shared SESSION_NOT_FOUND "requires an
active app session … run open first" — the same normalization the merged snapshot and diff
units already took on this exact operation. It cost three test failures before I applied it.

Reviewers should connect this to the admission-refusal question currently being grilled for wait
rather than reading it as test churn: every selector command inherits this surface.

Known gaps

1. The read alias retirement is deferred, and it costs a duplication

My start record classified the read dispatch alias as exclusively get's. That was wrong,
and I corrected it on the issue before writing the cutover row: readText() in
selector-read-shared.ts has three call sites across two commands — getCommand (get text @ref, get text <selector>) and findCommand (read-only find … get text). The alias is the
shared element-text mechanic of the selector read backend.

So this PR leaves the alias alive for find, behind
src/daemon/handlers/interaction-read-legacy-dispatch.ts, and the four platform read branches
now exist twice
: once in handleReadCommand (serving find) and once in
src/platform-runtime-element-text-host.ts (serving get). fallow dupes does not flag it — 5
groups / 207 lines, unchanged — because the two differ structurally, but it is the same four
branches and the wave's deletion discipline should not accept it.

Recommendation, and why it is now cheap: on find's branch, createBoundSelectorRuntime is
the single construction point for both find and get, and is/wait do not use
backend.readText at all. Widening the selector use with preferred: ['readTextAtPoint'] there
therefore binds the read for both consumers at once and lets the whole chain retire together — the
read registry entry and its dispatch: {} projection, DISPATCH_HANDLERS.read,
handleReadCommand, interaction-read-legacy-dispatch.ts, and the duplicated host branches.
That is a change to the seam find owns, so it needs find's sign-off rather than my unilateral
edit — flagged on the issue.

2. Size budget breached

Against the posted budget and the measured stack base 6984a1e09:

Metric Budget Actual Over
JS raw ≤ +1,500 B +4,638 B +3,138 B
JS gzip ≤ +600 B +1,342 B +742 B
npm tarball ≤ +1,200 B +1,218 B +18 B
npm unpacked ≤ +3,000 B +4,638 B +1,638 B

Root production TypeScript also grew (+300 / −57 lines) where I budgeted a decrease.
Workspace-package production TypeScript: +212 / −1 lines.

I am not asking for a revised budget. The overage is the duplication in gap 1 — the read
mechanics landed in the host without the legacy copy leaving — so the correct response is to
complete the joint retirement, not to re-baseline. Expect this table to go negative once
handleReadCommand, the read descriptor, and the legacy adapter are deleted.

3. Not claimed

  • The direct-iOS selector fast path (queryDirectIosSelector) still runs ahead of the bind in
    dispatchGetViaRuntime and reaches runAppleRunnerCommand from src/daemon/. It is co-owned by
    is, wait, and the Wave 5 offscreen-target probe (src/daemon/offscreen-target-probe.ts), so
    under ADR 0019 §6 it stays physically in place until its last consumer can move. Recorded as open
    shared debt; this PR does not claim it as migrated.
  • This unit is not the last selector unit: captureSnapshot in
    src/daemon/handlers/snapshot-capture.ts keeps its optional captureData? seam and the legacy
    captureSnapshotWithInteractor fallback stays in place.
  • The preferred operation's failure fallback in readTextForNode is retained under ADR 0019 §2's
    typed-reason carve-out (interaction_read_fallback with backend_read_failed /
    empty_backend_text — structured reasons, never message sniffing). The ADR 0011 path
    classification for it is not added here and belongs to the joint retirement above.
  • Shared iOS presentation, Android freshness, timeout-evidence, and overlay debt are untouched.
  • Residual live risk: Android, HarmonyOS, Linux, Web and every provider row are covered by
    fact/parity evidence only, per the device allocation.

Rebasing onto find

Mechanical, and net-negative for this PR:

  • take find's selector-capture-runtime.ts and selector-runtime-backend.ts wholesale;
  • drop my widening of captureData to (input: CaptureSnapshotInput) => … in
    snapshot-capture.tsfind keeps the zero-arg seam and builds the input in
    snapshot-runtime-capture-input.ts, which is the better split;
  • drop my captureData param on selector-capture-runtime.ts and the
    operations?.captureSnapshot threading;
  • replace resolveBoundGetRuntime's capture half with
    createBoundSelectorRuntime(params, { requireSession: true, command: 'get' });
  • keep the element-text half, and bind the preferred read at find's composition point per gap 1.

Trivial conflicts already identified: the WEB_QUERY_COMMANDS list (both units remove an entry),
the contracts and registry import lists, and the Linux runtime import list.

Scope and docs

Touched files: 50 (1,070 insertions, 89 deletions). Scope did not expand beyond the get
descriptor: no other Wave 4 descriptor, no Wave 5 interaction descriptor, no capability or legacy
fallback added.

Docs and skills are unchanged: CLI grammar, help, and user-visible get behavior are identical.
The coordination artifact on #1739 owns the exact denominator.

@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 +3.5 kB
JS gzip 765.0 kB 766.2 kB +1.2 kB
npm tarball 888.6 kB 889.5 kB +919 B
npm unpacked 3.10 MB 3.10 MB +3.5 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 30.1 ms 31.6 ms +1.6 ms
CLI --help 75.0 ms 73.4 ms -1.6 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/dispatch.js -1.1 kB -211 B
dist/src/sdk-batch-runner.js +730 B +174 B
dist/src/runtime4.js +452 B +103 B
dist/src/runtime2.js +219 B +45 B
dist/src/session2.js -10 B -11 B

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head 28b327ff0f36128f1024d5af752b3a2267c80979. Two code blockers remain:

  1. dispatchGetViaRuntime can still complete the direct-iOS selector path before resolveBoundGetRuntime. Once get declares platformExecution: device-runtime, ADR 0019 requires the full descriptor path to resolve/admit/bind before operating; calling this branch shared debt does not remove it from get’s denominator. It bypasses exact-owner facts/admission and the one-binding invariant. Move this path behind the admitted binding (or keep the descriptor legacy until its owning seam can move), with regression evidence that an eligible direct selector cannot operate before admission.

  2. readTextAtPoint returns Promise<string> and readTextForNode still catches any thrown error and falls back. The interaction_read_fallback diagnostic is assigned after the untyped failure; it is not the typed-reason outcome ADR 0019 §2 requires. Return a closed typed preferred-operation outcome, fall back only for its classified reasons, add the corresponding ADR 0011 path classification/contract coverage, and let unexpected errors propagate. This also restores the start record’s promised retirement of the generic catch.

The branch’s own reconciliation and size blockers also remain: it must reconcile with find to retire the duplicated read path, and the stated size budget is exceeded. Coverage’s sole ENOTEMPTY cleanup failure in request-save-script-transports.test.ts appears unrelated/infra-shaped and needs a green rerun after the code changes. No ready-for-human yet.

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

Copy link
Copy Markdown
Member Author

Both code blockers resolved

New head: c666150dfb5ce03449eb9dd15d5dada75fa5d1db (previous reviewed head 28b327ff0). Still draft.

Blocker 1 — direct-iOS selector path completed before admission

Fixed by ordering, not by reverting the descriptor. dispatchGetViaRuntime now runs
resolveBoundGetRuntime — resolve → admit → bind — before any target shape can reach the
device, including the ones the direct-iOS fast path can answer. The fast path is now a fast path
within an admitted request: exact-owner facts are inspected, admission can refuse, and the single
binding exists before the runner is ever queried.

To be explicit about what this does and does not claim: queryDirectIosSelector itself is still
the shared root mechanic co-owned by is, wait, and the Wave 5 offscreen-target probe. This unit
orders it inside get's request path; it does not claim its migration. That was the
distinction I had conflated — ownership versus ordering — and the review was right that ordering
is mine regardless of who owns the mechanic.

Red before green. New regression an eligible direct iOS selector cannot operate before admission: an id= selector the fast path would answer, on a device whose facts refuse
captureSnapshot. Against the pre-fix ordering:

FAIL  src/daemon/handlers/__tests__/interaction.test.ts >
      an eligible direct iOS selector cannot operate before admission
AssertionError: expected true to be false
  - false
  + true
  ❯ 259|   expect(response?.ok).toBe(false);

The fast path answered ok: true having skipped exact-owner facts entirely — exactly the bypass.
After the reorder it passes, and the test also asserts runAppleRunnerCommand was never called.

Blocker 2 — untyped fallback

readTextAtPoint now returns a closed ElementTextReadOutcome:

| Readonly<{ status: 'read'; text: string }>
| Readonly<{ status: 'unreadable'; reason: ElementTextUnreadableReason }>

with ElementTextUnreadableReason derived from its runtime list
(ELEMENT_TEXT_UNREADABLE_REASONS) rather than declared beside it, so the enumeration and the type
cannot drift. Reasons are no-text-at-point and surface-not-readable.

readTextForNode has no try/catch. It falls back to the captured tree only for a classified
reason, and classifiedFallbackReason switches exhaustively with a never arm — so an unexpected
error propagates and a new reason is a compile error at the consumer rather than a silent untyped
fallback. Blank owner answers are classified as no-text-at-point by elementTextRead instead of
being guessed at downstream, which removes the old "empty or failed?" ambiguity. Each host reader
classifies its own owner's "nothing here" answer; none of them catches.

This is the generic-catch retirement my start record promised, so it is a deletion I already owed.

Red before green. New regression propagates an unexpected live-read failure instead of falling back. With the pre-fix generic catch restored:

FAIL  src/daemon/handlers/__tests__/interaction-read.test.ts >
      readTextForNode > propagates an unexpected live-read failure instead of falling back
AssertionError: promise resolved "'snap'" instead of rejecting

Planted violation for the exhaustiveness gate. Adding a third reason to the single source of
truth without handling it:

src/daemon/handlers/interaction-read.ts(98,13):
  error TS2322: Type '"planted-unhandled-reason"' is not assignable to type 'never'.
FAIL packages/contracts/src/element-text-runtime.test.ts >
     the unreadable reason list is exhaustive over the reason union

Both the compiler and the contract test go red; both are green with the planting removed.

Contract coverage: packages/contracts/src/element-text-runtime.test.ts (8 tests) pins the
closed union, the exhaustive reason list, blank/undefined/null classification, exact text
preservation, and outcome freezing.

On the ADR 0011 path classification

I did not add a row to INTERACTION_DISPATCH_PATHS, and want that decision visible rather than
silently skipped. That matrix is scoped to mutating interaction dispatch paths — its command sets
are press/click/fill/longpress/hover/gesture, and its guarantees are disambiguation,
occlusion, parentOwnedTouchPoint, offscreen, nonHittable, verifyEvidence,
settleObservation. get is an observation command and the element-text read performs no
interaction, so every one of those cells would have to be inapplicable; the completeness gate
would be satisfied by a row that asserts nothing. ADR 0019 §2 asks for a "descriptor/ADR 0011 path
classification" — I took the descriptor half: the operation is declared preferred on the
descriptor's use, and the fallback is classified by the closed typed outcome above with contract
coverage. If you would rather see the matrix row anyway, say so and I will add it, but I did not
want to manufacture a vacuous one.

Behavior change, declared

An unexpected live-read failure (runner transport, helper crash) during get text on an
editable/expandable iOS node now fails the command instead of silently returning the captured
tree's text. That is the intended consequence of retiring the catch: answering from a stale tree
after an unclassified failure was the bug, not the feature.

Validation

  • Full vitest run --project unit-core behind the shared lock: 923 files / 6,984 tests pass
    (was 921 / 6,964; the delta is this change's new coverage).
  • pnpm check:layering: green, get still keeps exactly one platform-execution path.
  • fallow audit: clean on changed files. It caught an unused ELEMENT_TEXT_UNREADABLE_REASONS
    re-export on the platform facade during this pass; dropped it (root code needs only the type).
  • Typecheck across all packages + root: green.
  • pnpm check:affected --run && git push: PASS — "all runnable checks passed", pushed 28b327ff0..c666150df.

One correction worth recording, because it cost a cycle and generalizes. An earlier attempt
reported success that was not real: my chain was gate && push; echo "CHAIN_EXIT=$?", so the
trailing echo always exits 0 and the task notification said "exit code 0" while the log said
CHAIN_EXIT=1 / check:affected: layering failed. The && did its job — nothing was pushed —
but I reported a landing that had not happened. Read the log, not the wrapper's exit code.

That failure was itself a real finding: only check:affected runs the layering node tests.
A standalone scripts/layering/check.ts run — which is what I had been validating with — does not,
so it measures a narrower gate than CI. What it caught was facade-exports: an explicit façade
list must stay exhaustive over its source module, and I had removed
ELEMENT_TEXT_UNREADABLE_REASONS from the façade in the previous pass to satisfy fallow's
unused-re-export finding. The two gates pull in opposite directions over the same symbol.

I resolved it by satisfying neither: the runtime reason list is deleted outright. Exhaustiveness
was never enforced by it — it is enforced at the consumer by classifiedFallbackReason's never
arm, which is what the planted violation proves. The list was a second source of truth with no
consumer, waiting to drift; fallow was right that nobody used it. The union is now declared
directly and the contracts test keeps a local, type-annotated list for iteration.

The planted TS2322 was re-proved after that refactor, because an evidence claim has to
survive its own cleanup:

src/daemon/handlers/interaction-read.ts(98,13):
  error TS2322: Type '"planted-unhandled-reason"' is not assignable to type 'never'.

Live re-verification

Re-run on the assigned iOS Simulator iPhone 17 Pro (F7D6F9A4-…) after a rebuild and
pnpm clean:daemon, because both fixes change runtime behavior:

  • get text @ref, get attrs @ref, get text <selector>, and both not-found failure paths: all
    correct.
  • The direct-iOS fast path still fires after admission — querySelector runner count 9 → 12
    across the get text id=com.apple.settings.general request, which answered General.
  • Zero legacy read dispatches across every request in the session.
  • The §9 preferred-operation measurement holds under the typed outcome: required path alone
    511 chars on a [truncated] node, typed preferred read 1,126 chars, 3 readText runner
    calls. Session closed.

Still open, per the coordinator's sequencing

The read joint retirement (approved) and the rebase onto the re-scoped seam PR come next, in that
order, once #1876 merges. I have not re-baselined the size budget; the expectation is that removing
the duplicated read branches moves those numbers, and they will be re-measured then.

The scratch-dir wart is fixed — it now lives outside the worktree, so check:affected no longer
pulls .scratch/* into its coverage file set.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head c666150df. The typed preferred-read outcome is a real fix: classified fallbacks are closed, unexpected errors propagate, and the regression is non-vacuous. Two architecture blockers remain. First, successful direct-iOS get now occurs after admission, but still executes queryDirectIosSelector / runAppleRunnerCommand outside the selected bound runtime; the new test proves refusal ordering only. A device-runtime descriptor must operate through a declared narrowed operation, or remain legacy until that shared seam moves. Second, ADR 0019 §2 also requires explicit ADR 0011 path classification and contract coverage for the preferred fallback, and this head adds neither. Complete the planned #1876 reconciliation and joint read retirement before re-review: current growth is +739 net production lines and CI reports +5.1 kB npm unpacked, both heightened-review triggers, while duplicated legacy/read host paths remain. Android Smoke is an infrastructure rerun (unknown archive while installing the SDK image), not owner-code evidence. No ready-for-human.

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

Copy link
Copy Markdown
Member Author

The selector capture seam lands here, with get as its first consumer

New head: 4778a27512ee2e1c8e8c0efb7833ee4da908f792. Rebased onto #1876 @ 30435df1b (stacked, not waiting for merge).

Why the seam is in this PR and not #1876

It cannot ship standalone. ADR 0019 §10 prohibits a facet PR with no consuming command, and §6
admits an early substrate only when it is dead-code clean. With find's cutover deferred behind
the Wave 5 focus/type surfaces, nothing in production consumed the selector half and
check:production-exports went 19 → 20. get is its first consuming command, so it lands with
get. Same precedent as wave 0, where gateway facts inspection landed inside boot rather than
ahead of it.

The design is find's, adopted as handed over — resolveBoundSelectorCapture,
createBoundSelectorRuntime, the bound threading, selectorCaptureFixture, makeIosAppSession.

The one shape change, and why it was necessary

BoundSelectorOperations was built as a record precisely so the next unit could add its own
bound operation. But the read could not be populated: resolveSelectorCaptureRuntimePlan reused
captureSnapshotUse / captureSnapshotWithoutActiveAppUse — the same uses snapshot/diff bind
— which declare no preferred read, so the narrowed runtime never exposed one.

The alternative was dropping readTextAtPoint from get's use, which would have been a live-read
regression
for get text on editable iOS nodes (the measured 511 → 1,126 character case) and
would have discarded the §9 measurement that justifies the preferred declaration at all. This
wave does not narrow behavior to make a migration easier.

So, approved by the coordinator and constrained to be additive:

  • selectorCaptureUse / selectorCaptureWithoutActiveAppUse are declared alongside the
    snapshot uses. captureSnapshotUse and captureSnapshotWithoutActiveAppUse are unchanged and
    snapshot/diff bind exactly what they bound before; the only difference is
    preferred: ['readTextAtPoint'].
  • The read is surfaced through the existing arms of bindSnapshotCaptureRuntime, which reuse
    the same selectActiveAppSnapshot / selectSnapshotWithoutActiveApp selectors and compose
    selectElementRead onto them. One switch, two composed arms — no duplicated operation-selection
    logic and no second plan-to-operation dispatch.
  • admitAndBindSnapshotCapture gains an optional readTextAtPoint on its success shape.
    snapshot/diff never populate it.

Why the discriminants differ. Sharing 'active-app' / 'without-active-app' across both plan
families broke twice: a union-typed plan.use defeats bind's inference, and
{ readTextAtPoint?: … } is a weak type, so a snapshot projection with no overlapping key is
not assignable. Distinct discriminants ('selector-active-app', 'selector-without-active-app')
let the compiler narrow per family. The alternative was a cast to paper over an assignability hole,
which AGENTS.md rules out — typed signals beat working around the compiler.

What the seam replaced rather than accreted next to

  • resolveBoundGetRuntime and src/daemon/get-runtime.tsdeleted
  • src/daemon/__tests__/get-runtime.test.tsdeleted
  • elementReadRuntimeUse, elementReadRuntimePlan, and their façade exports — deleted
  • 'get' removed from the createSelectorRuntime capability union ('find' | 'is' remains;
    'find' survives until find's own flip, and is deletes the function outright)

get now runs through createBoundSelectorRuntime. R36's operation owners moved to the shared
seam: captureSnapshotselectActiveAppSnapshot, captureSnapshotWithoutActiveApp
selectSnapshotWithoutActiveApp, readTextAtPointbindElementRead.

Test fixture change that is NOT incidental churn

makeSession in src/daemon/handlers/__tests__/interaction-touch-fixtures.ts now returns
makeIosAppSession instead of makeIosSession. This is a real behavior surface, not tidying:

On an iOS-family leaf the without-active-app capture row is unavailable. A selector command run
against a session with no tracked app therefore resolves the without-active-app plan, is refused
at admission before it ever captures
, and returns the shared SESSION_NOT_FOUND "requires an
active app session … run open first" — the same normalization the merged snapshot and diff
units already took on this exact operation. It cost three test failures before I applied it.

Reviewers should connect this to the open question being grilled for wait, rather than reading it
as test churn: it is the same admission-refusal surface, and every selector command inherits it.

The read retirement stays deferred, deliberately

readTextForNode still serves find <q> get text, which reaches it through the legacy
createSelectorRuntime. So the four platform read branches remain duplicated
(handleReadCommand vs src/platform-runtime-element-text-host.ts) and my size breach is only
partly fixable here. That is recorded on the tracker and belongs to find's deferred cutover
scope alongside focus/type.

Why the remaining adapter is not a forbidden fallback (ADR 0019 §6). Which read the shared
backend uses is fixed by which command constructed the runtime — never by failure, family,
environment, or flag. A migrated command arrives with bound and its binding is authoritative,
including when the owner advertised no read, which is the complete required path. An unmigrated
selector command arrives without bound and keeps the legacy dispatch until its own descriptor
cuts over. get cannot reach the legacy adapter under any condition. That is the unmigrated
sibling's own path, which §6 permits — not a fallback for a migrated command.

Validation

  • Typecheck across all 17 packages + root: clean.
  • vitest run --project unit-core behind the shared lock: 924 files / 6,988 tests pass.
  • pnpm check:layering: green; get remains in the migrated-command list keeping exactly one
    platform-execution path.
  • fallow audit: clean on 73 changed files.
  • pnpm check:affected --run && git push: PASS — "all runnable checks passed", pushed c666150df...4778a2751 (forced update: the rebase onto refactor(daemon): one capture-input builder and one admit-then-bind step #1876 rewrote history; nothing was stacked on this branch at that point, and it is now a shared base that will not be rewritten without flagging).

For the units stacking on this branch

wait and is consume this seam. Two things not to drop:

  • CaptureSnapshotInput.signal + captureSnapshotSignal (from refactor(daemon): one capture-input builder and one admit-then-bind step #1876) are load-bearing for
    wait's poll deadline — each poll aborts that capture and waits for it to quiesce rather than
    racing and abandoning it, so a late capture cannot mutate session state.
  • BoundSelectorOperations is a record with capture and optional readText. Add operations as
    members; do not reshape it.

Per the coordinator's ruling I have not exported snapshotPlanUnavailableResponse — that lands
in wait's PR, which introduces its first external consumer.

@thymikee

Copy link
Copy Markdown
Member Author

Live re-verification of the shipped seam path

The seam swap changed both how get captures and how it reads, so the earlier evidence no longer
covered what ships. Re-run on the assigned iOS Simulator iPhone 17 Pro
(F7D6F9A4-4FCC-4DD7-AC0B-3280C9319CB9, confirmed in the runner log — no other device touched)
after pnpm build && pnpm clean:daemon, at head 4778a2751:

  • get text @ref, get attrs @ref, get text <selector>, and both not-found failure paths: all
    correct through createBoundSelectorRuntime.
  • The direct-iOS fast path still fires after admission — querySelector runner count 0 → 3
    across the get text id=com.apple.settings.general request, which answered General.
  • The bound preferred read reaches the platform through the seam: 3 readText runner calls.
  • Zero legacy read dispatches across every request in the session, so get never touched the
    adapter that still serves find <q> get text.
  • The §9 measurement holds unchanged under the seam: required path alone 511 chars on a
    [truncated] node, bound preferred read 1,126 chars.

Session closed; no sessions left open.

@thymikee

Copy link
Copy Markdown
Member Author

Blocking: #1739’s batch decision requires the shared read path to retire jointly, even while find remains legacy for focus/type. This head still lets read-only find construct an unbound backend, whose interaction-read-legacy-dispatch calls dispatchCommand(device, "read"); therefore the read registry/dispatch projection, DISPATCH_HANDLERS.read, handleReadCommand, and duplicate platform reader branches all ship. Route find … get text through the same bound selector operations, delete that legacy chain, and re-measure against the posted budget. The exact-head Size report is still +4.7 kB unpacked (+1.6 kB gzip), and no budget revision has been approved. Prior direct-iOS ordering and untyped-fallback blockers are resolved. CI is otherwise green; iOS Smoke remains in progress; still draft. No ready-for-human.

@thymikee

Copy link
Copy Markdown
Member Author

Out of draft at 4778a2751. All 28 required checks green, and the base is retargeted to agent/wave4-find so this PR's diff now shows its own 53 files rather than 60 including #1876's commit.

Stack: #1875#1877#1876main. GitHub will auto-retarget this to main when #1876 merges; a real rebase follows then, and since #1875 is stacked on this branch that rewrite will be coordinated rather than force-pushed under it.

Both blockers from your review at 28b327ff0 are fixed, each with red-before-green:

  • Admission now precedes the direct-iOS fast path. Red: expected true to be false — the fast path answered ok: true on a device whose facts refuse captureSnapshot, having skipped exact-owner facts entirely. The regression also asserts runAppleRunnerCommand was never called.
  • readTextAtPoint returns a closed ElementTextReadOutcome; readTextForNode has no try/catch, falls back only for classified reasons, and switches exhaustively with a never arm. Red: promise resolved "'snap'" instead of rejecting. Planting a third reason goes red in both gates (TS2322 … not assignable to type 'never' plus the contracts exhaustiveness test).

This PR also carries the selector capture seam, because get is its first consuming command — ADR 0019 line 686 prohibits a facet PR with no consuming command, and line 448 requires an early substrate to be dead-code clean, which a standalone seam was not (check:production-exports 19 → 20). The seam replaced rather than accreted: resolveBoundGetRuntime, get-runtime.ts and its test, and elementReadRuntimeUse/elementReadRuntimePlan with their façade exports are all deleted.

Three things worth the hardest look:

  1. The read duplication is real and deferred. The start record's classification was wrong — find <q> get text is a second live consumer — so the four platform read branches exist twice until find's cutover. The surviving adapter is selected by which command constructed the runtime, never by failure, family, environment, or flag, and get cannot reach it; that is the unmigrated sibling's own path, which §6 permits. The size budget is breached because of this and was not re-baselined.
  2. A behavior change is declared, not buried: an unexpected live-read failure on an editable iOS node now fails get text instead of silently returning stale tree text.
  3. makeSessionmakeIosAppSession is a behavior surface, not test churn — it is the same iOS no-app-session admission question investigated for wait, now filed as iOS: selector commands displace the app under test and return false negatives when no app session is tracked #1881.

Live-verified at this exact head on iPhone 17 Pro: all get paths through createBoundSelectorRuntime, direct-iOS fast path firing after admission (querySelector 0 → 3), 3 bound readText runner calls, zero legacy read dispatches, and the §9 measurement unchanged at 511 → 1,126 chars.

thymikee pushed a commit that referenced this pull request Aug 19, 2026
…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.
Base automatically changed from agent/wave4-find to main August 19, 2026 15:34
agent added 5 commits August 19, 2026 17:39
`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.
`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 head 52a1c9d: get still calls dispatchDirectIosSelectorGet after createBoundSelectorRuntime; that successful path reaches queryDirectIosSelector / the raw Apple runner outside the selected runtime projection. Admission ordering alone is insufficient because the bound operations are unused. The paragraph-long comment explaining why this exception is acceptable is evidence that the invariant is not obvious in code. Per the tracker ruling, retire the get shortcut (keeping the shared helper only for the offscreen probe), and make the eligible id= regression prove it uses the admitted capture path. Also refresh the body: it still describes the old base and deferred read alias although this head completed that retirement. No ready-for-human yet.

@thymikee

Copy link
Copy Markdown
Member Author

That review is against 52a1c9d, which was stage 1 of a two-stage push. The shortcut retirement you asked for is already on the branch at aa12c9ff418424367b895b41ef4eba43d7b455d1 — pushed as a fast-forward on top of 52a1c9d, so nothing was rewritten.

Verified on the pushed head:

symbol refs in selector-runtime.ts
dispatchDirectIosSelectorGet 0
dispatchDirectIosSelectorIs 2 — deliberately left for #1883
queryDirectIosSelector 5 — survives for the offscreen probe

Per the tracker ruling, the shortcut is retired in get and is and the shared helper stays single-copy for offscreen-target-probe.ts, which takes a plain session and cannot consume a bound operation. The deciding argument was that declaring querySelector now would ship it twice until Wave 5 moves the probe — the same deferred-duplication shape that this PR's read deferral was overruled for.

Two helpers went unreachable with the caller and are deleted: readDirectIosGetSelector and buildDirectIosGetResult. Both were get-only.

On the id= regression: two existing tests pinned the bypass. They were not deleted quietly — get text simple iOS id selector uses runner query without snapshot is replaced with one asserting the new truth (resolves through the bound capture, querySelector never called), and the ambiguity companion is removed with reasoning, since it asserted a refusal shape only the shortcut produced; ambiguity on that path now comes from the snapshot pipeline, which is already covered.

R36's legacyRetirement now names dispatchDirectIosSelectorGet, so the row asserts the retirement rather than describing the seam.

A cost stated rather than buried: this is a real latency regression. get text id=… no longer skips the tree capture, and nothing recovers it — the comparable path measures ~0.14 s → ~0.25 s. The deferred capability is recorded: querySelector returns as a declared, fact-admitted, §9-measured operation in a later unit that also moves the offscreen probe, retiring the root function in one move. When it is declared, get owes its own measurement; borrowing is's numbers will not do.

The body refresh you asked for is in progress along with the CI Size figure and iPhone 17 Pro re-verification on this final head — you are right that it still describes the old base and the deferred read alias, both of which this head completed.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head aa12c9ff4: the prior code blocker is resolved. get no longer reaches the raw direct-iOS shortcut, its get-only helpers are gone, the eligible id= regression now proves the bound capture path and rejects querySelector, and R36 owns the retirement. The joint legacy read chain is also fully retired; I found no new code blocker in this delta.

Not ready yet: the branch conflicts with current main, the PR body still describes the old base/deferred read alias, iOS Smoke failed with infra-shaped wait_capture_stalled (readableCaptures: 0), and Linux Smoke was cancelled. Rebase, resolve the interaction-fixture/test and ratchet conflicts, refresh the body/evidence at the resulting head, and rerun both smoke lanes. No ready-for-human until then.

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