Skip to content

fix(daemon): let a finishing mutation record the tree it acted through (#3471) - #3539

Merged
thymikee merged 12 commits into
fix/3471-gate-measurementsfrom
fix/3471-moving-target-gate
Oct 13, 2026
Merged

thymikee merged 12 commits into
fix/3471-gate-measurementsfrom
fix/3471-moving-target-gate

Conversation

@thymikee

@thymikee thymikee commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #3471. Top layer (3 of 3), on #3548 → #3538. This is the history that makes the gate fire, plus the disclosure surfaces.

A press resolves a point from one capture, so a press issued right after an action that animated the UI can resolve a point the control has already vacated and report success: true on the vacancy.

SessionState.deferredSurface becomes a two-variant claim instead of postGestureStabilization: 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). A mutation that resolved a target records the tree it acted through, stamped at the instant it finished, and runtime-session.ts carries it to the command layer. ADR 0014's device side-effect seam retires it. One field, one owner (deferred-interaction-outcome.ts). A recheck never overwrites an owed stabilize: an @ref press spends no capture, so a scroll's quiet-window loop can still be pending when that press finishes.

Two judgement fixes from review:

  • The baseline is a tree the caller can vouch for: the tree the action resolved against, or — only when --settle converged and stored its settled tree — the tree that proved the surface at rest. The signal is settle.diff, which the runtime attaches in exactly that conjunction; settled alone would license recording a tree the store refused as sparse. The old rule preferred session.snapshot whenever its node array differed from the resolution's, which could adopt a @ref frame tree or a freshness capture: a surface the action never looked at. MovingTargetBaseline.tree's doc now states both routes, which is the widening feat(interaction): add the moving-target gate the tap path consults (#3471) #3538's review asked for once a writer existed.
  • A corroborated iOS tap failure passed the resolved target's own tree to the finalizer. Omitting it reached the "acted without a daemon tree" rule and retired the baseline a previous mutation had recorded, leaving the next press unscreened.

The trigger is deliberately wider than the guard: any command that acted through a tree records a baseline, while only press/click are guarded — a fill that opened a keyboard can leave the surface animating for the press that follows, and that is pinned by a test.

agent-device press 'label="Slide target"'
# error.details.reason = target_still_moving, dispatched = no
# rect {x:894,w:139} → movedToRect {x:755,w:278}, waitedMs ≈ 600

32 files, 1,000 gross (918 added + 82 deleted). This layer also lands the ADR 0011 targetStability cells and their Layer-3 scenarios (the root coverage gate refuses an enforced cell with no scenario), and it is where #3548's review pointed the live evidence: the gate is only reachable once a writer exists, so the cells and the device numbers could not honestly sit below here. Deviation: src/commands/schema/cli-help.ts does not name the refusal — the workflow topic is at a hard 9,100-byte cap; the recovery ships in the per-command usage and the website docs, which also scope the guarantee to daemon-resolved targets and name the two routes that hand off before the check.

Validation

Tested at a99b315cb. The live numbers below were collected at d7a95c683, a pre-rebase head of this branch; everything since is comments, docs wording, and the judgement fixes above, each with its own test.

Android emulator ReactNative_API_35, throwaway SettleProbe fixture (native slide-in + its own hit counter, so the outcome is read from the app, not the command result); press "Probe slide[ N]" then immediately press 'label="Slide target"', no --settle:

case base 46dae0f2e (pre-stack, unpatched) head
400 ms slide-in, 16 trials landed 16 / miss 0 / refused 0 landed 16 / miss 0 / refused 0, median press 724 ms
3 s slide-in, 16 trials landed 11 / miss 5 / refused 0 — five presses reported success on a point the control had vacated refused 16 / landed 0 / miss 0, median 850 ms, waitedMs ≈ 600
idle press, 12 trials — median 265 ms in-window stationary, 243 ms not-recent; 1 capture, none from the gate
iPhone 17 Pro sim, 8 guarded presses — 8/8 succeeded, 0 refusals

The hit counter never moved on a refusal.

Live scope, stated rather than implied. The device runs above cover the ADB-backed Android helper and the iOS runner route. No Appium-backed Android run was made. The claim stays backend-neutral for two reasons that are checkable rather than asserted: the exposure is the daemon's own capture-to-dispatch distance (the gate sits in the shared runtime path, in front of runtime.backend.tap, with no backend branch anywhere in it), and the baseline writer is pinned on a non-ADB backend by the new daemon-route scenario, which drives the real iOS runner conversation through dispatchRuntimeInteraction and toRuntimeSessionRecord. The measurement claim that is backend-specific — "reporting frames in flight" — names ADB in the targetStability cell's own text. Open gaps, unchanged and stated plainly: (a) a guarded @ref press has not been re-verified live since the observation rule changed on #3548 (the probe fixture is gone and the shared emulator is held by another worker) — it is covered by the runtime-ref targetStability scenarios; (b) the live table has no delayed-start animation case (a control that has not begun moving when the press resolves, which is the case the stationary screening reads exactly like a finished transition). No device was available in this pass, so that row is owed by whoever next holds the emulator; #3538's review flagged it as needing no code change.

  • pnpm check:affected --run --base fix/3471-gate-measurements — result recorded in a comment on this PR.
  • typecheck, lint, format:check, check:layering (R7 field ownership), check:production-exports, check:doc-prose, check:doc-links, check:mcp-metadata, eager-closure-budgets — clean.
  • vitest run src/commands src/daemon src/interactions src/__tests__ test/integration/interaction-contract test/integration/provider-scenarios packages/contracts packages/selectors packages/ad-script — pass, including the targetStability scenarios (refusal, re-aim, stationary on both runtime paths), the gesture→press→capture test that pins the owed-stabilization guard, and the provider-integration contract that a press after a converged settle spends a tap and nothing else.
  • check:fallow --base origin/main flagged dispatchRuntimeInteraction at its complexity ceiling once both forwards were conditional spreads; passing the optional values directly clears it (7134070b7).
  • Mutation-checked for the wiring the review asked for: deleting preActionTree: runtimeResult.preAction from dispatchRuntimeInteraction, or the movingTargetBaseline carrier from toRuntimeSessionRecord, each reddens the daemon-route scenario — press A through the real route, press B screened against what A left, refusing with target_still_moving and dispatched: no over four runner captures and one tap. That scenario is the Layer-3 targetStability refusal case, now run through withIosContractDaemon rather than an injected session record. Deleting the corroborated path's preActionTree reddens a corroborated tap failure still records the tree it acted through; restoring the array-identity preference reddens the baseline is the tree the action acted through, whatever the session now holds; reading settle.settled instead of settle.diff reddens a converged settle that stored nothing keeps the acted-through tree; narrowing the writer to press/click reddens the trigger is wider than the guard on purpose.
  • Known flake surface, named in the test: the daemon-route scenario runs on the wall clock, because production installs no runtime clock on that route. A host loaded enough to starve the process past the 500 ms window sends press B idle and it taps — the same Unexpected provider call: ios.runner.tap a wiring regression gives. Pinning the window would mean threading a clock through the router deps for one scenario, so the risk is documented at the test instead.
  • Residual risk: worst case inside the window is ~2 captures + ~550 ms on a press, bounded by the existing daemon timeout policy; Maestro and native-ref are unguarded by declaration, not omission.

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-3539/

Built to branch gh-pages at 2026-10-13 16:41 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.19 MB 5.20 MB +1.3 kB
Package (unpacked) 5.19 MB 5.19 MB +1.3 kB
Package (download) 1.56 MB 1.56 MB +312 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.6 ms 26.5 ms -0.1 ms
CLI --help 76.0 ms 76.5 ms +0.5 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 28 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread src/daemon/interaction/internal/interaction-touch-runtime.ts
Comment thread src/commands/interaction/metadata.ts Outdated
Comment thread website/docs/docs/commands.md Outdated
Comment thread website/docs/docs/commands.md Outdated
Comment thread src/daemon/deferred-interaction-outcome.ts Outdated
Comment thread src/daemon/interaction/internal/interaction-touch-runtime.ts
Comment thread website/docs/docs/commands.md Outdated
@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the fix. The approach looks right, but the key daemon wiring has no test yet (reviewed at b06a9e0). CI shows 6 eager-closure-budget failures in Coverage that look unrelated to this PR, since the new import edge from packages/selectors/src/interaction-error.ts to packages/contracts/src/moving-target-gate.ts is already on the base PR #3538, which this PR stacks on. Both Smoke Tests jobs are still queued, and I know of no conflicts.

Two lines carry the whole fix, and no test reaches either of them. interaction-touch-runtime.ts:124 writes the baseline (preActionTree: runtimeResult.preAction), and runtime-session.ts:49 carries it to the command layer (movingTargetBaseline: baseline). moving-target-baseline.test.ts calls markDeferredInteractionOutcome directly. The runtime-selector and runtime-ref targetStability scenarios inject sessionState.movingTargetBaseline into createMemorySessionStore, which skips dispatchRuntimeInteraction and toRuntimeSessionRecord. If either line is deleted, every press leaves the session idle and the gate never fires, which is the old behavior, yet all added tests still pass. A later refactor could then bring back the stale-point tap that reports success. The rule to test is: a press that dispatched through the daemon leaves the next request's CommandSessionRecord carrying that press's resolving tree, stamped at its finish. Please add one daemon-route scenario (withIosContractDaemon, or a daemon-composition handler test on Android). Press A resolves against capture 1, and press B follows inside the window with its resolution capture showing the target at a different rect. Assert that B re-observes (capture count) and then either refuses with reason target_still_moving and dispatched no, or taps the rest point. The test must go red when either line is removed.

Not blocking, and you can take or leave these: the stabilize-variant JSDoc at session-state.ts:590 still points to markPostGestureStabilization, which is now markSurfaceStabilization; the chooseMovingTargetTree JSDoc and the comments at ref-frame.ts:97-101, runtime-session.ts:30-34 and interaction-guarantees.ts narrate control flow and could shrink to the invariant; and the JSDoc at deferred-interaction-outcome.ts:146 says a raw-coordinate press leaves the session idle, but a point target with --verify or --settle does set preAction in resolvePointInteractionTarget, so narrow the comment or skip recording for point targets if idle is the intent.

On the open inline threads, four still apply: metadata.ts:60-62 (name selector/@ref targets), moving-target-gate.ts:124 docs (movedToRect can be absent), commands.md:425 scope (not every click/press), and iOS corroborated baseline (retires the baseline). One lower-priority thread is unverified; I did not trace it. These two do not apply, so please resolve them. r4251043034: #3471 keys the window on the last mutating action, so recording after longpress, fill or hover is intended. r4251043108: both guarded paths dispatch through runtime.backend.tap, so the wording matches.

Before merge, add that press-to-press daemon-route test, and fix the eager-closure growth on #3538 by giving TARGET_STILL_MOVING_REASON a home already in the selectors closure or deferring the edge.

@thymikee
thymikee force-pushed the fix/3471-moving-target-gate branch from b06a9e0 to 1e26155 Compare October 12, 2026 20:24
@thymikee
thymikee changed the base branch from feat/3471-moving-target-judgement to fix/3471-gate-measurements October 12, 2026 20:26

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 29 files

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread src/daemon/__tests__/moving-target-baseline.test.ts Outdated
@thymikee
thymikee force-pushed the fix/3471-moving-target-gate branch from 07c2368 to 017113c Compare October 12, 2026 20:53
@thymikee
thymikee force-pushed the fix/3471-moving-target-gate branch from f0637b5 to e0aa183 Compare October 12, 2026 21:15
@thymikee
thymikee force-pushed the fix/3471-moving-target-gate branch from e0aa183 to 449db3c Compare October 12, 2026 21:18

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 1 file (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread website/docs/docs/commands.md Outdated
@thymikee
thymikee force-pushed the fix/3471-moving-target-gate branch from 75885ff to abecf2c Compare October 12, 2026 22:18
@thymikee

Copy link
Copy Markdown
Member Author

The code now looks good on most of what the b06a9e0 review raised, but one test it asked for is still missing. This review covers 0510124.

Fixed since the last round: the baseline selection, the iOS corroboration path and the docs wording. The eight earlier inline threads from Cubic that are fixed or intended can be resolved: #3539 (comment) (recording after fill/longpress/hover is intended), #3539 (comment) (claim now scoped to ref or selector targets), #3539 (comment) (movedToRect wording fixed), #3539 (comment) (scope of the check now stated), #3539 (comment) (chooseMovingTargetTree deleted), #3539 (comment) (preAction now forwarded on iOS), #3539 (comment) (says "dispatched"), #3539 (comment) (finishedAt bracketed in the test), and #3539 (comment) (wording now "cannot confirm a stable position").

The press-to-press test the last review asked for is still missing. preActionTree: runtimeResult.preAction writes the baseline, and movingTargetBaseline: baseline carries it to the command layer. No test goes through either line and then checks the gate. moving-target-baseline.test.ts calls markDeferredInteractionOutcome directly. The targetStability contract scenarios inject movingTargetBaseline into createMemorySessionStore. The new iOS corroboration test reaches line 201, not line 124. The settle-observation provider scenario only fails if the gate fires too often, so it still passes if either line is deleted. Deleting either line would bring back the #3471 behavior: every press leaves the session idle, the gate never fires, and a press after an animating action reports success on a vacated point, with all added tests green. The rule to pin is this: a mutation dispatched through the daemon leaves the next request's CommandSessionRecord carrying the tree that mutation acted through, stamped at its finish, and the next guarded press is screened against it. Please add one daemon-route scenario (withIosContractDaemon, or a daemon-composition handler test on Android) that uses the real dispatchRuntimeInteraction and toRuntimeSessionRecord. Press A resolves against capture 1. Press B follows inside the window, and its resolution capture shows the target at a different rect. Assert that B spends the re-observation captures, then either refuses with reason target_still_moving and dispatched no, or taps the resting point. The test must go red when either line is removed. I have not run those deletions myself, so this rests on reading every test that touches the marker or the gate.

Not blocking: the daemon infers "settle converged and stored" from whether the response field settle.diff is present in deferred-interaction-outcome.ts and then reads session.snapshot back, while the runtime already holds that tree as settledCapture. Could the runtime result carry one optional field, for example restingTree?: SurfaceScopedNodes beside preAction in packages/contracts/src/interaction.ts, set to settledCapture exactly when settled && stored && !surfaceChange? The daemon would then record runtimeResult.restingTree ?? runtimeResult.preAction, which would drop the settle parameter on finalizeTouchInteraction and markDeferredInteractionOutcome, the restProved flag and the session.snapshot read. These notes can be taken or left.

The one failing check is the iOS runner XCTest testAlertAcceptDoesNotActivateAReplacementWithASharedButton, and it looks unrelated, because the diff touches no Swift or runner file. Live Android evidence was collected at d7a95c683, before the baseline-selection change, so the --settle baseline path and the guarded @ref press were not re-validated live. I also did not check whether Appium-backed Android presses fill runtimeResult.preAction the same way ADB does. There are no conflicts. Before merge, the daemon-route press-to-press test that goes red when either of those two lines is removed needs to land.

@thymikee
thymikee force-pushed the fix/3471-moving-target-gate branch 2 times, most recently from b0d3f17 to 0849065 Compare October 13, 2026 06:51
@thymikee

Copy link
Copy Markdown
Member Author

The daemon-route press-to-press test is in at 08490658a, and it goes red on both deletions.

It replaced the injected-baseline selector refusal scenario (same runtime-selector targetStability cell, now through withIosContractDaemon instead of createMemorySessionStore, so no added scenario). Press A resolves and taps through the real dispatchRuntimeInteraction/toRuntimeSessionRecord; press B follows inside the window, resolves label=Continue at a different rect, and refuses with target_still_moving / dispatched: no with rect y=360 and movedToRect y=480. Capture and tap counts are asserted from the transcript (4 snapshots, 1 tap), so a session that goes idle shows up as B spending a tap on a vacated point plus an assertComplete mismatch, not a silent pass.

Verified by deleting each line in turn:

  • preActionTree: runtimeResult.preAction removed → fails (Unexpected provider call: ios.runner.tap — B dispatched).
  • the movingTargetBaseline carrier removed → fails the same way.

Your non-blocking restingTree note: not taken here. It moves the settled && stored && !surfaceChange conjunction from the daemon's read of settle.diff into the runtime, which is the same rule in a different module; it deletes the settle parameter, restProved and the session.snapshot read, and this layer is at 996 of the 1,000-line budget after the size trim plus this test. Happy to see it as a follow-up.

On Appium: preAction is produced by the shared runtime resolution (surfaceScopedNodes(capture.snapshot) in src/commands/interaction/runtime/resolution.ts), with no ADB branch, and this new scenario pins it on a non-ADB backend — the iOS runner route — so the write is not ADB-specific by construction. The live guarded @ref press remains the open evidence gap already noted in the body.

Gate at 08490658a: pnpm check:affected --run --base fix/3471-gate-measurements — all runnable checks passed. Broad sweep: 1,021 files / 7,679 tests pass.

@thymikee

Copy link
Copy Markdown
Member Author

This PR is ready. The earlier findings from 0510124 are fixed at 0849065, and no code issue remains.

Not blocking: the contract test in runtime-selector.contract.test.ts (line 276) installs no runtime clock, so on a very loaded host more than 500 ms can pass between press A finishing and press B's gate read, and B would tap and fail with the same "Unexpected provider call: ios.runner.tap" error a wiring regression gives; you can add an injectable clock to the daemon-scenario harness or note the risk in the test, and either is fine.

The open inline thread on the recorded window after fill, longpress and hover is resolved as intended behavior, because #3471 keys the window on the last mutating action and a test pins it: #3539 (comment). These threads are fixed at 0849065, so you can resolve them: #3539 (comment), #3539 (comment), #3539 (comment), #3539 (comment), #3539 (comment), #3539 (comment), #3539 (comment), #3539 (comment).

No conflicts. Smoke Tests is still running on 0849065 and has not failed. Because this PR changes the press and click dispatch route that smoke exercises, a failure there would need a look before merge. Once Smoke Tests passes, nothing else stands in the way.

I did not run the two mutation deletions. I judged the regression tests by reading the strict transcript harness and the baseline read, together with your report. The live evidence is from d7a95c683, and I did not re-check the settle.diff baseline branch or a guarded @ref press live since then. The latest change has no production logic change, so it adds no new live requirement. I also did not trace the Appium-backed Android preAction producer in resolution.ts.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 13, 2026
@thymikee
thymikee added this pull request to stack #3569 October 13, 2026 08:37
@thymikee
thymikee force-pushed the fix/3471-moving-target-gate branch from 0849065 to b3c7ddb Compare October 13, 2026 11:56
@thymikee

Copy link
Copy Markdown
Member Author

Gate evidence at this layer's head

pnpm check:affected --run --base fix/3471-gate-measurements on b3c7ddb85 — passed (exit 0, full run). pnpm format first, and the worktree was clean at the end of the run.

Recorded honestly: the first clean attempt exited 1 on two checks neither of which this stack touches — lint reported a failure with zero diagnostics in its output, and tmpdir-leaks-model failed its "a run whose owner alone was killed keeps its directory while a child still uses it" case, which sweeps the run-directory root shared by every worktree on this host while another agent's check:affected ran concurrently in a sibling worktree. Both re-ran green standalone at this head (lint exit 0, that test file 8/8), and the full slot re-run under the machine lock after that contention left passed end to end. This PR changes no scripts/ file.

Rebased onto this stack's layer 2 at 19089eee0, which is itself on #3538's 3e6c29f44, on origin/main at a95d2d539. Size: 32 files, 918 additions and 82 deletions — 1,000 gross, which is the ceiling.

Commit map from the head review was written against

08490658a is no longer this PR's head; the rebase rewrote every SHA and two commits were added. Patch contents of the ten rebased commits are identical (git range-diff shows only commit 1 differing, where main's settle code moved lines underneath it).

reviewed now
2cbc1c7e8 dc61e911a
e0fd3b221 35b4b5d3f
3229cebfa fcb7fab0e
2c2dcdd12 ed4be0bd6
449db3cf4 a383bf4a7
abecf2ca7 4832560a5
7516c65e5 e1e178ce4
b936b372e c3099e23d
ede3eed72 7f94134e3
08490658a e9ff16137
— 2c6a17794 docs: the baseline's real shape, where docs describe it
— b3c7ddb85 test: names the wall-clock window the daemon-route scenario trusts

Live-device numbers in the body were collected at d7a95c683, and no code has changed since.

Reply to the review at 08490658a

Where the settled tree comes from, and whether it is stable enough to press against. The settled tree is a capture of the live surface, taken after the transition converged — the same shape of observation a resolution's own pre-action tree is, one the caller already asked for. It is not a synthesized or cached tree. The settled branch used to write no baseline at all, which left the next press with no recent mutation to screen against and sent it to re-observe from scratch; that is the asymmetry the commit removes.

Your follow-up question is the real one: does convergence carry a stability claim? Convergence proves no further transitions pending, which is not rest. And the settle loop can return a converged sample whose positions it never compares. So this PR does not let the settled tree certify anything positionally: the baseline is the previous observation, never the permission. stationary grants rest only for a target this tree already shows at the resolved rect, on the same surface, and every other case spends two fresh captures that must agree on a position and a surface. The settled tree buys one thing only: an in-flight mutation with a settled baseline is not forced into a redundant settle, where it would pay one settle and then the recheck.

The honest limit is your original point stated exactly: a settled tree with this target at the resolved rect grants stationary on the same single-tree inference the resolution tree does, and an animation whose transitions are already drained but whose positions are still interpolating reads identically. Nothing in the repo can observe a position at two instants except the two-look recheck. Your restingTree alternative — publishing a tree only after the loop's last two samples agree on positions and surface — fixes it in the writer, is sound, and costs a settle-protocol change plus its tests, which this PR has no line room for at 1,000 of 1,000. Recorded as follow-up work rather than attempted here.

The gap in the live table. Agreed and unchanged: a delayed-start animation needs a new app scenario and a restingTree change are protocol changes, both out of this PR's budget. Recorded in the body.

The @ref guard. Not re-verified live since #3548 changed how a look is measured, and no device was used in this pass, so the body keeps that as an open gap rather than claiming it. The rule itself is covered by the runtime-ref targetStability scenarios, and a guarded @ref press is the first thing to run when the emulator is free.

Cubic threads. Every one of them is replied to at its own thread and resolved; 4252443260 (the stale doc sentence) is fixed at c3099e23d, verified against the runner sources.

@thymikee

Copy link
Copy Markdown
Member Author

The code in b3c7ddb looks right, and the one gap is that CI is still pending, so I can't call it ready yet. 12 checks are queued or in progress and none have failed. The change since my earlier review (0849065, #3539 (comment)) has no code edits: it is a restack onto 19089ee plus comment and doc edits, so no job's route is affected by the delta itself. I read the rebase by range-diff only. I did not re-review the base branch's own changes, and I did not run local tests. There are no conflicts. The two open inline threads on the shared finalizer still stand: #3539 (comment) (baseline for longpress/hover/fill) and #3539 (comment) (corroborated-failure route). Both are resolved as intended behavior and the delta does not touch them, so please resolve them. Not blocking, and you can take or leave it: movingTargetRestTree copies the body of surfaceScopedNodes and uses settle?.diff !== undefined as a stand-in for "the settle stored its tree". Could the interaction result carry the settled tree (SettleOutcome.settledCapture) next to preAction, so finalizeTouchInteraction records exactly the tree that proved rest? That would remove movingTargetRestTree, the diff-presence check and the session.snapshot read. If not, moving surfaceScopedNodes next to the SurfaceScopedNodes type in @agent-device/contracts/interaction and importing it on both sides would at least drop the copy. Before merge, CI must finish green on b3c7ddb, and the base branch fix/3471-gate-measurements must land first.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 13, 2026
…gh (#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.
…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.
…ound 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.
…oes 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.
…d 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.
)

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".
…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.
…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.
… 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.
…io 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.
@thymikee
thymikee force-pushed the fix/3471-moving-target-gate branch from b3c7ddb to a99b315 Compare October 13, 2026 16:21
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main: gate evidence and replies at a99b315cb

No code changed. Main moved (#3495, #3515, #3533, #3547, #3572, and the vitest 5 upgrade), so the whole stack was re-layered: this layer onto #3548's 0d8bf9f0d, that onto #3538's c4d517f60, that onto origin/main at f70224ad8. The twelve commits are the same ones you reviewed at b3c7ddb, with two earlier SHAs also re-pointed below:

reviewed at now
dc61e911a be334ff09
35b4b5d3f 683f1da43
fcb7fab0e a1aed7d0e
ed4be0bd6 0369c8ecb
a383bf4a7 bc5c09dfb
4832560a5 9fefab057
e1e178ce4 7134070b7
c3099e23d d7517b117
7f94134e3 c7bacbff5
e9ff16137 acc0ee1c8
2c6a17794 220e644b3
b3c7ddb85 a99b315cb

git range-diff reports all twelve as identical patches (=); nothing conflicted here — the one conflict in the stack was #3538's, in packages/selectors/src/interaction-error.ts against #3495's covering-node additions. Size is byte-for-byte the same as the head you reviewed: 32 files, 918 insertions, 82 deletions — 1,000 gross, still at the ceiling.

pnpm install --frozen-lockfile, pnpm build and pnpm typecheck are clean on the new base (vitest 5 included), and pnpm check:affected --run --base fix/3471-gate-measurements at a99b315cb — passed (exit 0), pnpm format first, clean worktree at the end.

The take-or-leave item: movingTargetRestTree, and the settle?.diff !== undefined test

Neither of your two shapes is one I can land honestly here, and the reason is a rule this stack already paid for once — not line budget.

Carrying SettleOutcome.settledCapture to the finalizer would replace a proxy with the fact, which is the direction this stack goes in generally. But it changes what "proved rest" means, and that is the thing your earlier review made me tighten. settle.diff is withheld in two distinct cases — the store refuses the settled tree as sparse, and a cross-surface transition (#2438) suppresses the diff — so "diff present" is the conjunction that the settled tree was actually stored and published on the surface the action acted on. That is a narrower claim than convergence, and it is the one a baseline needs: settledCapture exists for every converged run that captured, sparse refusals included, so under your version those runs would start recording a baseline whose tree the session does not hold — a tree that was never the surface anything resolved against. The test pinning today's rule is a converged settle that stored nothing keeps the acted-through tree, and it reddens the moment settle.settled or bare capture presence replaces settle.diff. So your option needs that case re-decided, not merely re-wired, and the state it would allow — a baseline rest was proved for, on a tree that was never published — is the class ADR 0014 refuses.

Moving surfaceScopedNodes into @agent-device/contracts/interaction has a cost the eager-closure gate (ADR 0027) charges without asking first. Every importer of that subpath is type-only today — all ten, import type, which the compiler erases, so the module sits in nobody's evaluated closure. A runtime export turns each into a real module edge, and the gate holds every façade at "no more modules than the merge-base evaluated". That is not hypothetical in this stack: #3538's c4d517f60 exists because importing one new contracts module pulled it and its kernel deps 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 fallback would trade an 8-line private helper for a change that has already broken this gate once from exactly that direction, and it would have to be measured against all 842 budget cases rather than assumed.

There is also a direction question underneath the copy: no production file under src/daemon/ imports the command runtime layer today — generic-settle.ts:111 states the rule where it reaches the settle surface ("never by importing commands/") — so a shared call means either a new daemon → commands edge or the contracts change above.

What I accept from the finding is that the duplication should not be silent, and both sides of it are documented at the head you reviewed: movingTargetRestTree's docblock names the shape a baseline keeps and why a stored tree must never smuggle in more, and markMovingTargetBaseline's says why session.snapshot is consulted only under restProved, and that preferring it merely because it differs is the same forbidden substitution seen from the other side. If you want either change regardless, each is cheap as its own PR; the move would also want git diff -M90% evidence under the refactor(move) budget exemption rather than living inside this behaviour change.

The rest

  • CI: green at the previous head and re-running at this one; nothing failed on either.
  • The two shared-finalizer threads (4251043034, 4251043096) read resolved on this PR, as does everything else — zero unresolved review threads across all three PRs.
  • targetStability cells and the Android trials, as fix(interaction): measure the moving-target gate by adoption, not bounds (#3471) #3548's comment now states: the cells and their Layer-3 scenarios are in this layer; 16 trials exist for the ADB helper and the iOS runner route; the missing Appium run and the missing delayed-start-animation row are named in the body as gaps rather than smoothed over.
  • Live evidence SHA: the table's numbers were collected at d7a95c683. Nothing since has touched a dispatch path — the later commits are docs, the wall-clock note, and this re-layer.

@thymikee

Copy link
Copy Markdown
Member Author

This PR is ready at the code level. The two things I raised in the earlier review at b3c7ddb are now settled, and a99b315 only restacks the same patch.

Not blocking: movingTargetRestTree in deferred-interaction-outcome.ts repeats the SnapshotState to SurfaceScopedNodes projection that surfaceScopedNodes already does in post-action-surface.ts, and generic-settle.ts has another inline copy. Since the daemon cannot import from commands/, could surfaceScopedNodes move next to the SurfaceScopedNodes type so all three sites share it? You can take this now, as a follow-up, or leave it.

The checks are still running at a99b315 (both Smoke Tests, Repo Guards, Typecheck & Package, Integration Tests), and none has failed. The PR changes the press and click finalizer route, which Smoke and the interaction-contract integration scenarios both exercise, so a failure in either needs a look. CI was green at b3c7ddb. I did not run tests locally, and I only read this restack through a range diff. The guarded @ref press and the Appium-backed Android run are still not validated live, as the PR body says.

Before merge, CI must finish green at a99b315, and the base branches must land first, #3548 (fix/3471-gate-measurements), then #3538. There are no conflicts that I know of.

On the other threads, the two cubic-dev-ai P2 threads do not apply, so please resolve them. The first is #3539 (comment). Recording a baseline after longpress, hover and fill is intended by #3471, and a test pins it. The second is #3539 (comment). The failure route goes through the catch path and does not pass preActionTree, so it records no baseline.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 13, 2026
@thymikee
thymikee merged commit 8d3a32a into main Oct 13, 2026
22 checks passed
@thymikee
thymikee deleted the fix/3471-moving-target-gate branch October 13, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(interaction): Android press on a moving target lands at a stale point and reports success

1 participant