Repository navigation
feat(interaction): add the moving-target gate the tap path consults (#3471) - #3538
Conversation
|
Two earlier runs of the same commit at 19:08 and 19:35 each exited 1 on exactly one timing-sensitive test unrelated to this diff, under host load average 70–330 on 12 cores:
Both runs' own slow-test gate reports the same file over budget, the file is untouched by this stack ( |
Design — moving-target gate (#3471)The defect in one lineA Where the "last mutating action finished at" timestamp and its pre-action tree live
| { kind: 'recheck'; baseline: MovingTargetBaseline } // { finishedAt, tree: SurfaceScopedNodes }Owner: It shares one field with the pre-existing Why these three pieces and why here:
Two transitions retire the baseline:
Which paths the gate covers
Keeping the stage in the command layer (one call in The decision, and why an idle press pays no extra capture
The window is anchored on the mutation's end (
Re-resolving rather than re-pointing is deliberate. The recheck spends the whole resolution path, so A The typed refusal and its hint
The hint names the recovery that works: run Deliberately not done
Design note for #3471 (layer 1 of 2). Both review passes are folded into this text: the same-node lookup lives with the owner of local-identity matching ( |
Size Report
Startup median (7 runs, lower is better):
|
There was a problem hiding this comment.
All reported issues were addressed across 12 files
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
|
The gate logic looks sound at 3e64c33, but one finding needs a fix before merge. The Coverage check is red, and the failure comes from this diff:
Not blocking, and you can take or leave these: declare Could most of this live in the one consumer? Only the I did not run the gate suite or mutate the guard, so the "each fails when reverted" claim is unverified here. I read only the file list of #3539, not its content. The open inline threads still stand, and none of them is resolved. P1: ref measurement provenance and delayed-start animation (a delayed-start case belongs in #3539's live run). P2: unmeasured first recheck, surface bundle scoping, weak refusal assertion and selector quoting. One lower-priority thread also applies: stale owner comment, which goes away if the literal moves into the registry. |
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
04be2b3 to
1626bc9
Compare
Gate evidence at this layer's head
$ pnpm check:affected --run
check:affected — diff origin/main...HEAD
Fail-open: selecting the full check set.
! packages/contracts/package.json [workflow-tooling] — workflow/tooling change can alter any gate
! scripts/layering/contracts-exports.snapshot.json [workflow-tooling] — workflow/tooling change can alter any gate
Selected 63 check(s):
...
[run] mutation-model: pnpm run mutation:test
...
check:affected: all runnable checks passed.
$ echo $?
0Notes for whoever reads this later:
Local-only runs recorded for completeness (CI is authoritative for the lanes skipped above): |
Correction to the design noteThe note above says:
That rule no longer exists as written. Review found nothing at the marking seam could tell a post-action capture from a What the baseline records now is a tree the caller can vouch for, by one of two routes:
A settle that timed out, a converged settle that stored nothing, a lone The intermediate state between "infer from array identity" and the above is worth recording, because it is the kind of simplification that reads well and breaks a contract: dropping the preference entirely made every baseline the pre-action tree, which broke Two related points the note above left implicit, both now pinned by tests:
|
|
The earlier findings from 3e64c33 are mostly fixed at 1626bc9: the selectors doc now names the gate's owner, the docblock states that the reason/hint pairing is a convention, and the import that broke the eager-closure check is gone. All 37 checks pass now. One finding from that review is still open, and I see no reply or change for it here or in #3548 at 40edc78.
Before merge, the shared helper must land in this PR or in #3548. I could not answer whether the baseline comes from the tree the action resolved against. Nothing in this PR writes The open Cubic threads need a pass too. These still apply and are fixed in #3548 (the writer-less code is unreachable until #3539): measured set from bounds alone and no surfaceBundleId match. Three more P2 threads still apply: doc says rect still changing, test uses only notEqual, and selector quoting. The delayed-start animation thread needs no code change, but #3539's live table has no delayed-start case. Two threads are fixed at this head and can be resolved: owner named in selectors doc and reason/hint convention docblock. |
1626bc9 to
3e6c29f
Compare
Gate evidence at this layer's head
Rebased onto Reply to the review at
|
… it (#3471) Three places said less than the code does, all found in review: - `MovingTargetBaseline.tree` named only the pre-action tree. #3538 asked for this doc to be widened when the writer landed, because it cannot be read truthfully from that layer: the converged-settle case records the tree that settle published as rest, not the one the action started from. - `markMovingTargetBaseline` listed "a raw coordinate" among the routes that leave the session idle. A point target with `--verify` or `--settle` DOES capture a tree in `resolvePointInteractionTarget`, so the idle list is the routes with no tree of their own, not the point-shaped ones. - `DeferredSurface.stabilize.baselineSignature` still pointed at `markPostGestureStabilization`, which is `markSurfaceStabilization` now. Also shrinks five comments that were narrating control flow rather than stating the invariant: the field-owner claim, the retirement comment, the carrier comment, and two guarantee cells. Line counts matter at this layer's budget, and a claim in prose is worth less than the same claim in a name.
|
I re-reviewed #3538 at 3e6c29f. The earlier findings from 1626bc9 are addressed, and I found no new problems in the code. The remaining gap is evidence. I did not read #3539's baseline writer or the Not blocking, and fine to take or leave: Could Two of the older inline threads still apply: the baseline writer gap and the delayed-start animation. The first is fixed in #3548 at I know of no conflicts. Smoke Tests and the Mutants shards are still queued or running, none have failed. The diff does overlap their routes (the selectors and interaction-settle shards, and Please wait for those checks, then merge #3538 together with #3548, which holds the tolerance fix, and #3539, which holds the writer, the guarantees cell and the live evidence. The duplicated tolerance rule clears only if the stack lands together. |
|
At 3e6c29f this PR now conflicts with main. The code verdict and the evidence gap from the last review are unchanged. Please rebase onto main; the next review will check the conflict resolution together with the requested evidence. |
…3471) A press resolves a point from one capture, and nothing between that capture and the dispatch looked at the surface twice. A press issued right after an action that animated the UI can therefore resolve a point the control has already vacated and report success on the vacancy — measured on ADB Android, whose helper reports frames in flight. This adds the judgement: a pure window/stability decision in @agent-device/contracts, the same-node identity lookup it screens with in @agent-device/ad-script, and the runtime seam that spends the window in front of the tap, re-resolving once and tapping where the target came to rest. The session does not yet record the history the gate reads, so every press still answers `idle` and pays nothing.
…dapters read (#3471) The eager-closure gate (ADR 0027, `scripts/__tests__/eager-closure-budgets.test.ts`) holds every façade that reads an interaction reason to "no more modules than the merge-base evaluated". Importing `TARGET_STILL_MOVING_REASON` from `@agent-device/contracts/moving-target-gate` pulled that module and its kernel dependencies into six closures, `packages/maestro/src/daemon-port/daemon-runtime-port.ts` and `packages/replay-port/src/daemon-port/session-replay-maestro-runtime.ts` among them. The reason is a literal in `INTERACTION_ERROR_REASONS` again, the one vocabulary every adapter already reads, so the leaf imports nothing; the hint stays in contracts, which is on no façade path.
3e6c29f to
c4d517f
Compare
… it (#3471) Three places said less than the code does, all found in review: - `MovingTargetBaseline.tree` named only the pre-action tree. #3538 asked for this doc to be widened when the writer landed, because it cannot be read truthfully from that layer: the converged-settle case records the tree that settle published as rest, not the one the action started from. - `markMovingTargetBaseline` listed "a raw coordinate" among the routes that leave the session idle. A point target with `--verify` or `--settle` DOES capture a tree in `resolvePointInteractionTarget`, so the idle list is the routes with no tree of their own, not the point-shaped ones. - `DeferredSurface.stabilize.baselineSignature` still pointed at `markPostGestureStabilization`, which is `markSurfaceStabilization` now. Also shrinks five comments that were narrating control flow rather than stating the invariant: the field-owner claim, the retirement comment, the carrier comment, and two guarantee cells. Line counts matter at this layer's budget, and a claim in prose is worth less than the same claim in a name.
Rebased onto main; gate evidence and replies at
|
|
The PR is ready at c4d517f. The merge conflict from the earlier review (3e6c29f) is gone, and I see no conflicts now. Not blocking: the docblock in packages/selectors/src/interaction-error.ts (https://github.com/callstack/agent-device/blob/c4d517f/packages/selectors/src/interaction-error.ts#L6) says the module "imports nothing", but line 1 now has the I ran no tests, typecheck or mutation runs, so your check:affected pass at c4d517f is not verified by me. Smoke Tests and the Mutants shards (kernel-errors, interaction-settle, selectors, target-annotation-serde, snapshot-occlusion) are still queued or running, and none has failed. The selectors and interaction-settle shards do exercise this diff. The gate cannot fire until the #3539 writer lands, so a Smoke regression from this layer is unlikely. I did not read the #3548 or #3539 heads, and I did not re-check how the gate handles optional refs on find and web nodes, since the gate is dormant here. The cubic-dev-ai threads are already settled, so please resolve them: the P1 on measured-from-bounds (#3538 (comment)) is fixed in #3548 and the code cannot run until #3539 writes the baseline. The P1 on delayed-start animation (#3538 (comment)) needs no code change and is recorded as an open gap in #3539's live table. The P2 on the still-changing rect doc (#3538 (comment)), the P2 on Surface scope in targetShownAtRest (#3538 (comment)), the P2 on the generic rejection assertion (#3538 (comment)) and the P2 on apostrophe quoting in the hint (#3538 (comment)) are all fixed in #3548 and dormant at this head. The two P3 threads (#3538 (comment) and #3538 (comment)) are fixed at this head. Once Smoke and the Mutants shards finish green, merge this together with #3548 (tolerance and thread fixes) and #3539 (baseline writer and live evidence). |
#3471) (#3539) * feat(daemon): let a finishing mutation record the tree it acted through (#3471) The history the gate reads. The session's deferred surface becomes a two-variant claim — `stabilize` (the capture side still owes a quiet-window loop) or `recheck` (a mutation dispatched through a captured tree whose own effect may still be animating) — and a mutation that resolved a target records that tree at the instant it finished, so the next press can ask whether its own coordinates are still in play. ADR 0014's device side-effect seam retires the record, and a press never erases a stabilization the capture side still owes. The `targetStability` matrix cells, their Layer-3 scenarios on both runtime paths, and the caller-facing help/docs land with it: the refusal is observable from here. Closes #3471. * chore(gates): name the deferred-surface owner the recheck variant shares (#3471) * fix(daemon): record only the tree a mutation actually acted through (#3471) `chooseMovingTargetTree` preferred the session snapshot whenever its node array differed from the tree the action resolved against, reasoning that a `--settle` or `--verify` capture must have published something newer. Array identity cannot establish that: a freshness capture and a `@ref` press's authorized frame reach `session.snapshot` the same way, so the marker could record a surface this action never looked at — the exact substitution its own docblock forbids, and a `stationary` verdict made against the wrong tree. The baseline is now the acted-through tree, always. A settled press simply owes its successor one re-observation instead of being answered from a tree of unknown provenance. The corroborated-failure path also dropped the tree. That sibling call reached the same finalizer without `preActionTree`, so a corroborated iOS tap retired the baseline a previous mutation had recorded and left the next press unscreened — a dispatch that touched the device behaving like one that never looked at a tree. It now passes the resolved target's own tree. Help and website docs now scope the guarantee to what the gate actually covers: ref and selector targets on the runtime path, dispatched (not "tapped") at the resting position, `movedToRect` optional, and a raw-coordinate press explicitly outside it. Refusing to narrow the WRITER to press/click: the ADR 0011 cells scope which commands the gate guards, while this marker asks whether a mutation just left an animating surface, and a `fill` that opened a keyboard or a `longpress` that started a drag answers that the same way a press does. The breadth is now pinned by a test rather than left implicit. * test(daemon): bracket the recorded baseline instant with the reads around it (#3471) The bound was "within the last second" of assertion time, so a CI pause between the mark and the comparison could fail the test while the marker itself stayed correct. Reading the clock before the call and again in the assertion keeps the claim the test actually makes — the stamp is the mutation END, not the action's start — and removes the slack the bound needed. * docs(website): name the two dispatch routes the moving-target check does not cover (#3471) The paragraph claimed the check runs for any @ref or selector target, which held for neither exclusion. `click @ref` on a backend that dispatches to the element itself returns from `maybeTapRefTarget` before the guard is reached, and the Maestro non-hittable coordinate fallback is a fused runner request that never resolves a point at all. Both are now stated as the exceptions they are, and "nothing was tapped" becomes "nothing was dispatched" for the same reason the command metadata changed verb. * fix(daemon): let a settle that proved rest record the tree that proved it (#3471) Removing the session-snapshot preference took the baseline back to the tree the action started from, and that broke a contract the repository already encodes: a press following a converged `--settle` spends a tap and nothing else (test/integration/provider-scenarios/settle-observation. test.ts asserts the runner transcript directly). A settled press is the case the screening exists to answer cheaply — re-observing a surface the settle already proved quiet is the round trip `--settle` was built to remove. The provenance problem the review found stays fixed. Instead of inferring "newer" from a differing node array, the finalizer forwards the action's own settle observation and the marker reads the one field that carries the fact: `diff`, which the runtime attaches only when the loop converged AND stored its settled tree as the session snapshot within one surface. `settled` alone is not that fact — a sparse capture is refused by the store and a cross-surface transition withholds the diff, and in both cases the session's newest tree belongs to some earlier capture. Everything that is not a stored settled tree keeps the acted-through tree: a timed-out loop, a lone `--verify` capture, a freshness capture, a `@ref` frame. * refactor(daemon): hand the finalizer its optional values directly (#3471) Both forwards were conditional property spreads, and the complexity ceiling counts each one as a branch on the already-branching dispatch path — `check:fallow` flagged the function at threshold on the previous commit. The finalizer's parameters are optional-with-undefined, so passing the value directly states the same fact with two fewer branches: an absent settle or an unresolved target forwards undefined, which is what the marker already treats as "no such evidence". * docs(website): say the motion check could not confirm rest, not that the target moves (#3471) The refusal covers two outcomes and the paragraph described only one. A re-check that never measured a position refuses for the same reason a target that kept moving does, and a reader told the target "was still moving" reads missing evidence as measured motion — then waits for motion that the gate never observed. Same wording the typed reason's own docblock already uses. * fix(interaction): report a refused press as an unconfirmed position, not as measured motion (#3471) A `target_still_moving` refusal covers two outcomes and all three of its surfaces described only one. The gate also refuses when a re-observation never measured a position at all — the `@ref` case where reconciliation kept the authorized frame node — so "the target was still moving" reported an observation the gate had not made, in the message, in the published hint, and in the website paragraph. A caller told the target is moving waits for movement; the actual recovery there is to re-resolve the target. All three now state the confirmation that failed. The reason literal is untouched: `reason` is the branch point, and the prose around it is not allowed to be more specific than the code that raised it. The unmeasured `@ref` refusal asserts both halves of that — the new wording present, the motion claim absent — so the text cannot drift back into a claim this path cannot support. * refactor(daemon): cut the duplicate settle-branch test and the repeated baseline reasoning (#3471) * docs(daemon): put the baseline's real shape in the docs that describe it (#3471) Three places said less than the code does, all found in review: - `MovingTargetBaseline.tree` named only the pre-action tree. #3538 asked for this doc to be widened when the writer landed, because it cannot be read truthfully from that layer: the converged-settle case records the tree that settle published as rest, not the one the action started from. - `markMovingTargetBaseline` listed "a raw coordinate" among the routes that leave the session idle. A point target with `--verify` or `--settle` DOES capture a tree in `resolvePointInteractionTarget`, so the idle list is the routes with no tree of their own, not the point-shaped ones. - `DeferredSurface.stabilize.baselineSignature` still pointed at `markPostGestureStabilization`, which is `markSurfaceStabilization` now. Also shrinks five comments that were narrating control flow rather than stating the invariant: the field-owner claim, the retirement comment, the carrier comment, and two guarantee cells. Line counts matter at this layer's budget, and a claim in prose is worth less than the same claim in a name. * test(interaction): name the wall-clock window the daemon-route scenario trusts (#3471) The press-to-press scenario goes through the real daemon route, and production installs no runtime clock on that route, so the gate's 500 ms window runs on the wall clock: press B has to reach its gate read within 500 ms of press A finishing. A host loaded enough to starve the process past that sends B idle, and B spends the tap — the same `Unexpected provider call: ios.runner.tap` a wiring regression gives, which is exactly the failure this scenario exists to catch. The alternative was threading a `clock` through the router deps so the daemon composition could install one, for a guarantee the scenario does not own; the harness-level `createContractClock` only reaches the in-process runtime, which this scenario deliberately does not use. So the risk is named where it can be read instead of paid for.
|
Summary
Part of #3471 (layer 1 of 3) — the judgement half of the moving-target gate; #3471 closes at the top layer.
A
pressresolves a point from one capture, and nothing between that capture and the dispatch ever looked at the surface twice — so a press issued right after an action that animated the UI can resolve a point the control has already vacated and reportsuccess: trueon the vacancy. #3539 carries the base/head device numbers.This layer is the decision and the seam, not the history it reads:
@agent-device/contracts/moving-target-gate—movingTargetGateDecision(is this position owed a second observation, from the session's mutation clock) and the rest question (movingTargetRectsStable), pure over values the caller already holds. Window 500 ms anchored on the mutation's end, sample spacing 50 ms. The rect band is not this module's fact to hold: fix(interaction): measure the moving-target gate by adoption, not bounds (#3471) #3548 hoists it toareCapturedRectsAtSamePositionin@agent-device/kernel/rect-center, which is the one place that decides whether two capture rects count as one position (the post-gesture no-effect comparison already ran that comparison against its own copy of the band). Landing here first and hoisting in the next layer is what feat(interaction): add the moving-target gate the tap path consults (#3471) #3538's review asked for; the stack merges together.@agent-device/ad-scriptnodeRectByIdentity— the same-node lookup the screening needs, placed with the owner of local-identity matching rather than beside its only caller; fails closed when an identity is absent, shared, or unmeasurable.src/commands/interaction/runtime/moving-target-gate.ts— the seam intapCommandbetween resolution andruntime.backend.tap: waits out the window, re-observes twice, taps where the target came to rest, refuses withtarget_still_moving/dispatched: 'no'.12 files, 958 gross. No behavior change here: nothing writes
movingTargetBaselineyet, so every press answersidleand pays one session-record read —grep -rn --include='*.ts' movingTargetBaseline src packages/*/src scripts(excluding tests) returns only the type's declaration and this gate's own read. Each layer consumes what it adds.MovingTargetBaseline.treeis documented as the pre-action tree only; #3539 widens that doc when the writer proves the converged-settle case, because the doc cannot be written truthfully from this layer.Validation
Tested at
3e6c29f44.pnpm check:affected --run --base origin/main— result recorded in a comment on this PR. Selection is fail-open (the layer adds apackages/contracts/package.jsonexport, which rewires package resolution and can alter any gate), so this is the full check set, taken under the machine-wide gate lock.typecheck,lint,format:check,check:layering,check:production-exports,eager-closure-budgets— clean. The reason literal stays in@agent-device/selectors, the leaf every interaction adapter already reads, so the gate module keeps importing nothing new at module scope.vitest run packages/ad-script packages/contracts src/commands/interaction— pass; each new gate test fails when its guard is reverted.Guarantees
The ADR 0011
targetStabilitycells — the runtime enforcement onruntime-selectorandruntime-ref, and the intentional waivers on the paths that hand off before the seam (direct-ios,native-ref,coordinate, the two Maestro routes,target-drag) — are declared in #3539, the layer that makes the guard reachable. A cell has to name a guarantee the shipped paths actually provide, and until the top layer writesmovingTargetBaselineno press is screened.