Skip to content

fix(settle): resolve the default quiet window from the capture it settles - #3522

Merged
thymikee merged 6 commits into
mainfrom
fix/3470-settle-quiet-per-capture-backend
Oct 14, 2026
Merged

thymikee merged 6 commits into
mainfrom
fix/3470-settle-quiet-per-capture-backend

Conversation

@thymikee

@thymikee thymikee commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Part of #3470's follow-up work; does not close it. #3482 and #3512 have both merged, so this now targets main directly and carries only its own 8 files.

Summary

--settle asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge answers well inside that, so the loop spends a whole extra capture holding a window the tree has already satisfied — and the window is paid twice per settled command (the settle's final window, then again as the next command's baseline capture).

The loop now prices the window from the capture it performs, not the one the action was performed against: an unrequested --settle resolves 300 ms when that read came from the simulator snapshot bridge, and 500 ms for every other read — the Apple runner's own tree read, an Android capture from either producer (android-uiautomator over ADB or appium-source over Appium), a web agent-browser read, an iOS tree from a provider acquisition, or one that declares no provenance at all — because only the bridge carries the measurement. It re-prices on every capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports the window it ran on (settle.quietMs) so the response states which default it applied.

Keying on the baseline's producer was the first cut here and is wrong: the window governs the loop's own reads, and a stored ref frame or session tree can be several commands stale or served by another producer. Keying on the channel would be wrong too — the bridge and the Apple runner share xctest and read at different costs. --settle-quiet still outranks both, and wait stable keeps the general default of its own.

Evidence for 300 ms

iPhone 17 simulator, one run per window per rep, order alternating so host load falls on both arms alike:

Screen 500 ms 300 ms captures
text entry, Catalog search 859 ms 664 ms 3 → 2
press, product Add to cart 844 ms 647 ms 3 → 2

Accuracy held: diff snapshot -i two seconds after the settled response found no further change for the text entry at either window (0/6 both), and one further change in three of six runs for the press at either window — that screen keeps animating after the press whichever window is asked for, so the shorter window changes the wait, not the verdict's accuracy. (These A/B runs, and the live runs below, were taken on the pre-split build that still carried the presented-surface trigger since lifted to #3570; both arms request their window explicitly, so the comparison is this layer's window arithmetic alone.)

Validation

Tested at fd4c0c7cd (d371573d2 plus the review follow-up that folds the quiet-window closure into the loop, no behaviour or test change). settle-default-quiet.test.ts pins the window per capture producer in both mismatch directions (bridge baseline + runner re-read widens to 500 ms; runner baseline + bridge re-read narrows to 300 ms) and pins flag precedence; mutation check, measured when the key moved (6f43f54e6) and unchanged in shape here: keying the rule back on the baseline fails three of those cases plus one daemon case. generic-settle.test.ts pins the three default-window routes on an injected fake clock (a 25 ms poll, a 30 ms sleep, deterministic elapsed arithmetic — no real-time budget races).

Arming is re-checked on every capture until one qualifies, and only the caller passing no pre-action projection rules the confirmation out for the run — the two per-capture inputs (viewport-root completeness, declared capture channel) decide nothing on their own. Three cases pin it, each with the 7-node settings table as the baseline so the tiny-baseline hatch cannot carry it, and each requiring waitedMs >= 1500: after a rootless, an empty, or an other-channel first capture. All three fail at 84f170e31 (settling at 1004 / 802 / 502 ms).

The per-capture re-pricing briefly broke the layer beneath it: requiredQuietMs received the freshly resolved default each poll instead of the previous requirement, so the 1.5 s dismissal floor survived only while each consecutive capture kept qualifying. The comparison refuses a node-less side, so one empty mid-animation projection dropped the floor and the next empty read settled the run, well before the dismissal had met its floor. The floor is now latched once a capture proves the dismissal and maxed against the current window rather than replacing it (pinned by a dismissal confirmation is not dropped by a later empty projection; it fails against the pre-fix head, and passes against layer 1's source, which held the floor only by accumulating).

The live run for this head — default window, three reps, armed on all three, with the late diff and the producer evidence — is in the review-response comment on this PR (## The live run, head d371573d2, iPhone 17 Pro). The runs below predate it and are kept for the window-arithmetic comparison they carry.

Live on the simulator after the earlier rework: default press --settle reports quietMs: 300 at 658–664 ms over two captures; --settle-quiet 700 still wins at 1050–1076 ms; wait stable is unaffected. Those runs were taken on the pre-split build, which still carried the presented-surface trigger since lifted to #3570; at this head the trigger is main's, which arms on a superset of transitions, so the floor can only hold at least as well here — the window arithmetic they measure (quietMs, flag precedence, capture counts) is this layer's and unchanged.

Gate

pnpm check:affected --run --base origin/main at 84f170e31: all runnable checks passed (exit 0) — 514 related test files / 3,998 tests, Fallow clean on the 8 changed files — held under the machine-wide lock, started 2026-10-13T10:56:14Z. format, lint, tsc -b, check:layering, build and vitest related all pass; the 1500 → 1500ms unit fix in the MCP/Node field description is covered by the schema/parser/registry suites (47 files, 309 tests) with no text pin against it.

Note on scope

An earlier revision of this stack carried, one layer down, a presented-surface arm that keyed on the baseline's node count — the defect found in #3512's review (#3512 (comment)), where a dismissal whose baseline is a full tree stops confirming. That arm is not in this stack anymore: #3512 is now the window decoupling alone and keeps main's trigger, and the narrowing (with that rule and the measured route table) sits on top of this layer as the draft #3570, blocked by #3565. So this layer arms exactly where main arms, only at every requested window, and nothing here depends on how #3565 resolves the fact.

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.24 MB 5.24 MB +2.0 kB
Package (unpacked) 5.24 MB 5.24 MB +2.0 kB
Package (download) 1.58 MB 1.58 MB +790 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 41.1 ms 42.0 ms +0.9 ms
CLI --help 123.4 ms 128.1 ms +4.7 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 9 files

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

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

Comment thread src/commands/post-action-observation-grammar.ts Outdated
Comment thread packages/contracts/src/client-gesture.ts Outdated
Comment thread src/commands/interaction/runtime/settle.ts Outdated
Comment thread packages/command-registry/src/flag-definitions-action.ts Outdated
Comment thread src/commands/interaction/runtime/settle.ts Outdated
Comment thread src/__tests__/daemon-composition/generic-settle.test.ts
@thymikee

Copy link
Copy Markdown
Member Author

The settle change looks correct at 6ad9bf3, and CI is green, including Smoke Tests, with no conflicts. Before merge, please decide who owns the quiet-window fact and fix the 300 ms scope text so it names the simulator snapshot bridge. This PR is also stacked on #3512, which should land first.

Not blocking, and you can take or leave these: (1) the window is keyed on the baseline's producer at https://github.com/callstack/agent-device/blob/6ad9bf3/src/commands/interaction/runtime/settle.ts#L175, but the loop compares the loop's own captures. The baseline can come from an earlier stored snapshot, and the bridge can fall back to the Apple runner mid-session. A bridge baseline followed by runner captures then gets 300 ms, and a runner baseline followed by bridge captures misses the speedup. The rule should be that the default window follows the source of the captures being compared. (2) Help, grammar and client docs say 300 ms applies to an iOS simulator or healthy iOS interactive tree (https://github.com/callstack/agent-device/blob/6ad9bf3/packages/command-registry/src/flag-definitions-action.ts#L214), but the code applies it only to the simulator snapshot bridge. A simulator capture served by the Apple runner keeps 500 ms. Deriving that text from the same constant would keep it honest. (3) The roughly 17-line benchmark comment on SIMULATOR_BRIDGE_SETTLE_QUIET_MS could shrink to two lines, with the measurements moved to docs/evidence. (4) The two new scroll --settle cases in generic-settle.test.ts use the real settle clock, about 900 ms of sleeps. An injected clock would match settle-default-quiet.test.ts.

Could settleAfterAction pass quietMs as optional, so that runStableCaptureLoop resolves the default from the first capture's provenance? The 300 ms value would sit beside the producer table in ios-snapshot-acquisition.ts, or be exported from platform-apple, instead of a literal in shared runtime. That would remove SurfaceScopedNodes.captureSource, both writers and the generic-route threading, and the loop could be tested directly. One owner would cover both routes and follow the source actually read, including after runner fallback. pollMs would then be derived after the first capture, as it already is from quietMs. Nothing blocks this, though the quietMs and pollMs setup would become lazy.

The open inline threads on the 300 ms wording at #3522 (comment), #3522 (comment) and #3522 (comment), and on the baseline-keyed window at #3522 (comment), still stand. The two lower-priority threads on the benchmark comment and the real-clock tests (#3522 (comment) and #3522 (comment)) also still apply. The first two are marked resolved, but the text is unchanged at this commit.

I did not rerun the live iPhone 17 A/B or the affected-checks run, so the PR body's measurements are unverified here. I also did not exercise a mid-loop fallback to the runner. Point 1 comes from reading the fallback paths in snapshot-route.ts.

This review covers 6ad9bf3. The branch has since moved to 9bf62c7, a rebase onto the updated #3512 that only renames a test in the base. I will confirm the review against the new head on the next pass.

@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 10 files (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 src/commands/interaction/runtime/stable-capture.ts Outdated
Comment thread src/commands/interaction/runtime/stable-capture.ts Outdated
@thymikee
thymikee force-pushed the fix/3470-settle-quiet-per-capture-backend branch from 856999e to 2bcaa2b Compare October 12, 2026 21:20

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 6f43f54e6, which the run predates — the block ran from 17 lines of benchmark data down to a rule statement, and no device, timing, or run count remains in the source:

/**
 * The default quiet window a capture earns. The iOS simulator snapshot bridge answers well inside
 * the general default, so holding the general window on that read spends a whole extra capture on a
 * window its tree had already satisfied. Keyed on the producer, not the channel: the bridge and the
 * Apple runner share the `xctest` channel and read at different costs, and only the bridge carries
 * the measurement. Every other read — the runner's own tree read, the Android helper, a capture that
 * declares no provenance — keeps {@link DEFAULT_STABLE_QUIET_MS}.
 * (PR #3522 holds the interleaved before/after medians and the late-diff accuracy check.)
 */

I kept the numbers in this PR's description rather than opening a file under docs/evidence/: that directory's existing entries are exact-head sweeps with raw artifacts, digests, and named corpus tags, and this is a six-sample manual A/B on one simulator. Pointing at the PR number does rot once this merges, so the pointer stays a parenthetical rather than a {@link} anyone is meant to resolve.

@thymikee
thymikee force-pushed the fix/3470-settle-quiet-per-capture-backend branch from 5e5842a to 88fea6f Compare October 12, 2026 22:02
@thymikee

Copy link
Copy Markdown
Member Author

The code in 88fea6f looks right to me, but I would like the latch commit covered by one live run before merge. The CI checks are green (14 reported, none failing), and there are no conflicts.

The live simulator runs in the PR body predate the latch commit, so they do not show it working. Please run one live simulator settle that reaches a confirmed dismissal, then keeps polling on the 300 ms bridge window. The output should show the quiet window staying at 300 ms for the rest of the run. I read the code and the test fixtures by hand. I did not run the mutation checks, and I did not read every line of the fake-clock wiring in generic-settle.test.ts.

Not blocking: defaultQuietMsForCapture compares the producer string and keeps the 300 ms literal in shared runtime, while capture-kit already has exhaustive per-producer tables (ios-snapshot-acquisition.ts), and the latch comment cites fixture numbers (150 ms, 302 ms) where it should state the invariant that a proven dismissal holds for the rest of the run. You can take or leave both.

Could the quiet window be one exhaustive settleQuietMs table next to that truncation table, with 500 for undefined provenance and non-iOS producers? The loop would then only call that accessor, and most of the extra lines left would be the pacing restructure, which could be its own refactor.

Of the eight bot threads, all eight look fixed at this head, so you can resolve them: r4250825442, r4250825462, r4250825469, r4250825474, r4250825481, r4250825483, r4251997139 and r4251997145.

Before merge, #3512 (layer 1) must land first, including its decision on the presented-surface fact, and this PR must then be retargeted.

@thymikee
thymikee force-pushed the fix/3470-settle-quiet-per-capture-backend branch from 88fea6f to 7f9b305 Compare October 13, 2026 07:52
@thymikee
thymikee force-pushed the fix/3470-settle-quiet-per-capture-backend branch from 7f9b305 to 93e79cc Compare October 13, 2026 08:01
@thymikee
thymikee force-pushed the fix/3470-settle-quiet-per-capture-backend branch from 93e79cc to b78640a Compare October 13, 2026 09:06
@thymikee

thymikee commented Oct 13, 2026 •

Copy link
Copy Markdown
Member Author

Gate — fix/3470-settle-quiet-per-capture-backend @ 84f170e31

Supersedes the earlier gate comment on this PR, which was taken at b78640a35 before #3482 and #3512 merged and this layer was restacked onto main.

Local, held under the machine-wide lock, started 2026-10-13T10:56:14Z, against a clean tree.

pnpm install --frozen-lockfile              exit=0
pnpm build                                  exit=0
pnpm format                                 exit=0  (tree clean afterwards)
pnpm check:affected --run --base origin/main exit=0

check:affected selected the affected set (7 checks, no fail-open) and reported all runnable checks passed: format:check, lint, typecheck (tsc -b), check:layering, check:fallow --base origin/main (No issues in 8 changed files, 22.78 s), build, and vitest related over the 8 changed files — 514 files / 3,998 tests.

What the restack changed in this layer's own files: generic-settle.test.ts only. Its dispatchGeneric helper had lost its session parameter on main (#3510 rewrote that helper's runtime-binding seam), so this layer's three new default-window cases now seed the session and omit the argument — the helper's body never read it. The post-action-observation-grammar.ts description also picks up the reviewer's 1500 → 1500ms unit fix on the line this layer already rewrote. Nothing else in the 8 files differs from b78640a35.

@thymikee

Copy link
Copy Markdown
Member Author

This follow-up on 88fea6f covers b78640a. The earlier review items are fixed in the code, but one regression and the missing live run remain.

Fixed: the quiet window now re-prices from each loop capture, the confirmation latch is present, and the doc and test comments are corrected. The earlier review asked for a live latch run, and none was posted for this head.

The remaining problem is in broadTransitionConfirmation. It decides arming once and stores null after the first capture. But broadTransitionBaseline reads per-capture inputs, capture.nodes.some(isViewportRootNode) and capture.backend. The base re-ran this on every capture until one armed. Take a dismissal whose pre-action tree has more than 5 nodes, such as a full-screen modal or form sheet. If the first post-action capture is rootless or empty mid-animation, the head rules the confirmation out for the whole run. The base would arm on the next rooted capture. So an iOS dismissal that the base and main hold for the 1.5 s floor now settles on the 300 ms window, which is the #1831 transitional-tree case the floor exists to prevent. It also means this layer does not arm exactly where main arms, as the PR body says. The rule: a run may keep the arming answer only from inputs that are constant for the run, which are the baseline nodes, the platform and the app bundle id. Store null only for those, and re-check the per-capture completeness and backend on each capture until one arms. Please add a regression with a baseline above 5 nodes, a rootless or empty first capture, then rooted replacement captures, and require it to hold for at least 1500 ms. I read this from the code and did not run it against either commit, and I have not seen on a device that a non-tiny modal dismissal gives a rootless first capture. Does the base comment about a post-dismissal capture omitting the viewport root match what you see?

After that fix, please post one live iPhone simulator run of press --settle with the default window (no --settle-quiet), dismissing a presented modal into a full screen. The response must show settle.quietMs 300 and waitedMs of at least 1500. Include the response JSON and the head sha. The PR body says the current live runs predate the latch commit and use the earlier trigger.

On the open inline threads, the latch on confirmedQuietMs is fixed at this head, but it depends on the arming change above. These threads are fixed at this head and can be resolved: simulator bridge naming, 300ms scope in client-gesture, re-pricing per capture, flag help naming the bridge, removed benchmark block, fakeClock in the settle test, and quietMs doc on every return.

Smoke Tests is still running and no failure has been reported. This change touches the settle loop that the --settle smoke steps exercise, so a later failure there could relate to it. Before merge, the arming check needs to re-run on each capture until one arms, the rootless-first-capture regression needs to be added, and the live default-window modal-dismissal run needs to be posted. I did not run the mutation checks you describe.

An error occurred while trying to automatically change base from fix/3470-broad-transition-scope to fix/3469-ios-keyboard-chrome-interactive October 13, 2026 10:18
@thymikee
thymikee force-pushed the fix/3470-settle-quiet-per-capture-backend branch from b78640a to e8bcff3 Compare October 13, 2026 10:41
@thymikee
thymikee changed the base branch from fix/3470-broad-transition-scope to main October 13, 2026 10:41
@thymikee
thymikee force-pushed the fix/3470-settle-quiet-per-capture-backend branch from e8bcff3 to 84f170e Compare October 13, 2026 11:02
@thymikee

thymikee commented Oct 13, 2026 •

Copy link
Copy Markdown
Member Author

Smoke Tests on this layer — investigation, per the note on #3512

The standing guidance is that a smoke failure on a settle diff is probably related, so this records what each failure actually was rather than only that it went green. The iOS lane is the only lane that has failed on this layer's history; Android, macOS and Linux are green at every head that has reported one.

Head 93e79ccb2 (08:01Z) — job/115192846055, attempt 1, still red, never rerun. Failing step Run fixture-backed iOS simulator E2E smoke, inside assertRegularVisibleDepthFrontier (test/integration/ios-simulator-e2e/live-snapshot-depth-frontier.ts:19), which is the scenario's first command:

agent-device open com.callstack.agentdevicelab --relaunch
  --launch-url agent-device-test-app:///snapshot-depth --udid 5FEB61C5-…
→ code COMMAND_FAILED, "Simulator device failed to open agent-device-test-app:///snapshot-depth."
  NSPOSIXErrorDomain code=60 — Operation timed out (ETIMEDOUT from xcrun simctl openurl)

This one is dispatched, so I will not wave it away: the timeout is simctl handing a URL to a freshly relaunched simulator, before any capture is taken and therefore before runStableCaptureLoop, the --settle default, or the window resolution this PR changes can be reached. That is a reachability argument, not a passing rerun — this specific job has no second attempt.

Head b78640a35 (09:06Z) — run 38373920347, iOS lane, now showing green on attempt 3. Two red attempts sit behind that badge (attempt 1: IOS_RUNNER_CONNECT_TIMEOUT + runnerConnectFailureReason: xcodebuild_exited_early, dispatched: "no", xcodebuild's Test crashed with signal kill before establishing connection; attempt 2: kind: daemon_startup_failed, startupTimeoutMs: 15000), both dying in Preflight iOS runner through public CLI (prepare ios-runner) before any command is dispatched. Attempt 3 went green at 10:21Z.

Same tree, three failures and one pass. 93e79ccb2 and b78640a35 share a tree (645cdcdd2…, git diff between them is empty), so all four iOS attempts above ran identical content: it failed once on a dispatched simctl openurl timeout, then twice pre-dispatch on preflight, then passed. The honest summary is that this lane is unreliable for this content, not that any single failure has been root-caused to the host.

On the reruns themselves: I reran, and that was the wrong order for the 93e79ccb2 failure. The two preflight signatures justified a rerun on reachability grounds; the deep-link timeout did not, and its lane was never retried. Recording it here so the green badge at b78640a35 is not read as covering 93e79ccb2, and so the count of attempts on this lane is visible: this layer has consumed three iOS attempts to get one pass.

Still owed: the iOS lane re-running at 84f170e31 (content delta from b78640a35: test-helper argument adaptation plus the 1500 → 1500ms help-text fix) was queued when this was written, along with everything else on the pool. I'll append the result here. If a smoke failure comes back inside a settle or interaction step, or with dispatched: "yes", I stop and treat it as diff-caused.

@thymikee

Copy link
Copy Markdown
Member Author

The 300 ms default window is not safe yet, because one of the two problems from the earlier review at b78640a is still open at 84f170e. The thread fixes and the latch are in. Checks are still pending, and no conflicts are known.

The arming decision is still made once, on the first capture. In stable-capture.ts, broadTransitionConfirmation is set on the first capture and stored as null for the rest of the run. broadTransitionBaseline returns null on per-capture inputs: capture.nodes.some(isViewportRootNode) and capture.backend. Take an iOS dismissal whose pre-action tree has more than 5 nodes, such as a full-screen modal or form sheet. If its first post-action capture is rootless or empty mid-animation, or reports a non-XCTest backend, the confirmation is ruled out for good. A default press --settle then settles on the 300 ms window instead of the 1.5 s floor that base and main hold. That is the #1831 transitional-tree case the floor exists for, so the PR body's claim that it arms where main arms does not hold. The rule: the run may keep the arming answer only from inputs that stay constant for the run (baseline nodes, bound platform, appBundleId), and may rule out with null only on those. Viewport-root completeness and the capture backend must be re-checked on every capture until one arms. Please add a regression with a baseline above 5 nodes, a rootless or empty first capture, then rooted replacement captures, and assert waitedMs >= 1500. It should fail at 84f170e.

Could the arming question be split by input lifetime? Rule out once from the run-constant inputs, then check completeness and backend on each capture inside requiredQuietMs until one capture arms and latches. That is the base's ??= re-check behavior plus the new latch, and it removes the need for the null tri-state comment.

This change touches the device-facing iOS settle path, and the latch with per-capture re-pricing has not been run on a device. The live runs in the PR body predate the latch commit and used the earlier trigger. Once the fix above is in, please post one iPhone simulator press --settle with no --settle-quiet, dismissing a presented modal into a full screen, on the new head sha. Include the response JSON showing settle.quietMs 300 and waitedMs >= 1500, plus the capture producer (simulator-ax-bridge).

Eight earlier inline threads from the other review are fixed at this commit and can be resolved: grammar description, client-gesture scope, per-capture price, flag description, doc block, injected clock, confirmedQuietMs latch and quietMs doc. The latch thread is fixed in form, but it only helps once arming succeeds, which is the first finding above.

I read the base and head code paths and did not run the tests or reproduce the first finding. I have not seen on a device that a non-tiny modal dismissal gives a rootless or empty first capture; the base code's comment is the only source for that. I did not check the mutation results or the 302 ms pre-fix measurement. All 14 non-passing checks were still queued when I looked, and none had failed. The diff changes runStableCaptureLoop, which the --settle steps in the iOS Smoke Tests lane exercise, so a later failure inside a settle or interaction step would overlap this diff. The earlier red runs (simctl openurl ETIMEDOUT, runner preflight) happened before any capture, per your notes. There are no conflicts.

Before merge, please re-check per-capture arming on every capture, add the regression for a non-tiny baseline with a rootless first capture, and post the live default-window modal-dismissal run on that head.

@thymikee

thymikee commented Oct 14, 2026 •

Copy link
Copy Markdown
Member Author

Restacked on main, arming re-checked per capture, live latch run posted — d371573d2

Two commits on top of origin/main (ba7730af0): the rebased layer, then d371573d2 for the 11:35Z finding. CONFLICTING is gone; GitHub reports the PR MERGEABLE.

The arming fix

broadTransitionConfirmation no longer stores a ruled-out answer for the run. broadTransitionBaseline returns undefined — an open question — whenever the capture in hand cannot support the comparison, and the caller puts the question to the next capture. It re-asks until one arms, so the two per-capture inputs the finding named (viewport-root completeness and the declared capture backend) can no longer cancel the floor for a run that had not finished trying (#3470).

The split is by input lifetime, as the comment asked. The caller passing no pre-action projection is the only ruling the run keeps: nothing in a later capture can supply a baseline, and that is also the one input that was already capture-independent. null is gone from the type, which removes the tri-state comment with it. A non-iOS or backend-less run now re-evaluates each capture rather than being memoized as ruled out — same cost as main, whose ??= re-ran the same expression until it resolved. Arming is otherwise byte-for-byte main's: same trigger, same inputs, no narrowing.

The latch is kept and now sits on top of the re-check: the comparison refuses a node-less side, so without the latch a mid-confirmation empty read hands the loop its short window back. Together they are the base's ??= re-check plus the new latch, which is what the comment asked for.

The regression

Three cases in stable-capture.test.ts, each with the full-screen settings table as the baseline (elementSettingsSnapshot([1138, 1423, 2144, 2434]), 7 nodes, so <= TINY_STABLE_TREE_NODE_COUNT cannot carry it) and rooted replacement captures after the bad first read. All three assert waitedMs >= 1_500:

settle arms the confirmation on the first rooted XCTest capture after a rootless first capture
settle arms the confirmation on the first rooted XCTest capture after an empty first capture
settle arms the confirmation on the first rooted XCTest capture after a first capture read through another channel

At 84f170e31 all three fail, which is the check the finding said the test owed. The rootless case is the one described; I added the empty first capture because it fails the same way for a different reason (the comparison's node-less refusal), and the other-channel case because capture.backend was the second per-capture input named. git stash of the source fix reproduces all three failures:

× ...after a rootless first capture                       settled after 1004ms
× ...after an empty first capture                          settled after  802ms
× ...after a first capture read through another channel    settled after  502ms

On your question about the base's comment: I have not reproduced a rootless first capture on a device either, and I can now say where it comes from. git log -S on that comment lands on 9fb307aa6 (derive transition backend from capture, #1851, 2026-08-18), which added both the tiny-baseline escape hatch and the sentence. So it is a claim from that fix's own investigation, carried forward as a code comment ever since, and nothing on either side of this stack has independently confirmed it on a device. The unit cases here pin the behaviour without depending on the claim being true.

The live run — head d371573d2, iPhone 17 Pro BC54AC11

Default window, no --settle-quiet, dismissing the automation-lab modal (automation-close-sheet) into the full lab screen. Fresh pnpm build and a fresh daemon on the head, RN lab app com.callstack.agentdevicelab served over Metro. Three reps; the raw settle object from a fourth:

{"settled":true,"waitedMs":1833,"captures":5,"quietMs":300,"timeoutMs":10000,
 "diff":{"additions":27,"removals":3,"unchanged":1}}
rep settled quietMs waitedMs armed captures late diff (t+2s)
1 true 300 1862 yes 5 {"additions":0,"removals":0,"unchanged":28}
2 true 300 1838 yes 5 {"additions":0,"removals":0,"unchanged":28}
3 true 300 1816 yes 5 {"additions":0,"removals":0,"unchanged":28}

Armed on every rep: waitedMs ≥ 1500 while quietMs stayed 300, which is the floor held on top of the bridge's own window rather than instead of it. The late diff is the accuracy half — 28 unchanged / 0 additions / 0 removals two seconds after the settled response on all three, so the loop did not return early on a still-moving tree. The dismissal's own diff is identical across reps (27 additions / 3 removals / 1 unchanged).

Control on the same dismissal, asking for the general window explicitly:

{"settled":true,"waitedMs":2116,"captures":5,"quietMs":500}

--settle-quiet still outranks the per-capture default, and the floor still applies underneath it.

Producer. The owner asked for the capture producer. settle does not carry one, so this is two facts rather than one field. First: quietMs: 300 is only reachable from producer === 'simulator-ax-bridge' — the Apple runner on the same xctest channel prices 500, and settle-default-quiet.test.ts pins that both ways — so the reported window is itself a producer-keyed fact. Second: the daemon records an ios.snapshot-source.acquire error only when the bridge refuses a capture and the read falls back, and that count over the whole session log was 8 before and 8 after the measured window, so no measured capture fell back. All 8 sit inside the earlier open request with foreground-owner-unverified. Caveats, both stated plainly: the bridge never names itself on the success path, so this shows "no refusal during the window" rather than a stamped producer, and press writes no per-request log at all, so I cannot show the loop's own five captures individually.

Session settle3522live closed, daemon stopped with --clean, and device status --udid BC54AC11-… reports no claims.

Your two non-blocking notes

  • The 1500 → 1500ms fix on the shared line — kept, it is in post-action-observation-grammar.ts.
  • One exhaustive settleQuietMs table beside the truncation table — declined here, and I think it belongs to a different commit. IOS_SNAPSHOT_TRUNCATION_EVIDENCE is exhaustive because iOS snapshot backend convergence and fast Simulator observation #2188 invariant 5 needs an answer for all four producers: a capture that reported nothing about truncation is only "not truncated" if its producer would have noticed. The window needs the opposite — the one producer with a measurement gets its window, and every other read, including Android, web, Linux and HarmonyOS, keeps 500 because that is main's number and no iOS table can enumerate their costs. An iOS-keyed table would either carry a non-exhaustive default branch, which is the defaultQuietMsForCapture you pointed at wearing a table costume, or an IosSnapshotProducer union that cannot type the non-iOS reads the accessor would still have to answer for. defaultQuietMsForCapture is already one predicate over the producer; the table would move the same fact, not simplify it. Happy to be overruled.
  • The fixture numbers in the latch comment — fixed. The comment now states the invariant, and the 302ms figure left the test comment with it. The two remaining literals are settleQuietMs: 150 as a named caller (tester-army/e2e, both commits back on main) and --settle-quiet 499 vs 500, which are the old bar's own operands, not measurements.

Threads and gate

All eight threads you listed were already resolved; the API now reports all eight isResolved: true, and no thread on the PR is open.

Full local gate rerun under the machine-wide lock at d371573d2, clean tree, started 2026-10-14T13:03:14Z:

pnpm install --frozen-lockfile               exit=0
pnpm build                                   exit=0
pnpm format                                  exit=0   (tree clean afterwards)
git diff --exit-code                         exit=0
pnpm check:affected --run --base origin/main exit=0   → all runnable checks passed

check:affected selected 526 of 1,782 test files as related to the 8 changed files and ran them all green — 4,010 tests, 0 failures — with Fallow reporting No issues in 8 changed files (27.18 s), alongside format:check, lint, typecheck, check:layering and build.

Two notes on the lock, since the wait was long. The lock I found was an abandoned file: its recorded owner PID had been dead since an 11:26 gate, while an unrelated check:affected for another worktree was running without taking the lock at all. I did not steal it. I waited, and only reclaimed it once the owner file was unchanged from the dead PID I first observed, that PID was still dead, and the host had no gate-ish process left — the one-gate-per-host rule held throughout, and the reclaim is logged with those three conditions at each step. My gate released the lock at 13:06:01Z; another worker took it 8 seconds later, and that lock is not mine.

CI at this head is running now; I will report the outcome here, including the iOS Smoke lane, which the note on #3512 says to treat as diff-related on a settle diff until shown otherwise.

#3570 is restacked onto this head (git rebase --onto d371573d2), still a draft, GitHub reports it MERGEABLE. Its two commits came across unchanged apart from the two spots where the rebase had to land on the rebased parent: the daemon test's param bag (composedPlatformServicesFixture, which main renamed, plus this layer's clock injection) and the presented-sheet tree fixture, which main moved out of src/daemon/__tests__/support/ — that one now sits beside the root test that reads it rather than gaining a @agent-device/daemon/testing/... export entry for a single consumer. The draft also needed one real adaptation: three of the per-capture cases above pin an input this layer removes (viewport-root completeness on the capture), so on top they assert the surviving invariant — a rootless or empty mid-animation read still decides nothing and the run arms — plus the capture-channel case, which stays per-capture under the narrowed trigger. Its own four suites pass (63 passed, 1 expected fail) and tsc -b and lint are clean there.

@thymikee

Copy link
Copy Markdown
Member Author

The PR is ready from the code side. At d371573 the earlier findings from 84f170e are fixed, and no open inline thread still applies.

Not blocking: createStableCaptureWindow in src/commands/interaction/runtime/stable-capture.ts keeps a baseQuietMs copy that the loop can always derive from lastCapture, so you could compute params.quietMs ?? defaultQuietMsForCapture(capture.snapshot) inline and drop the closure, and the pacing and recovery extractions could land as a separate move-only PR; take or leave this.

Of the other reviewer threads, none still apply. These are all fixed at this head, so please resolve them: #3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment).

Smoke Tests and Coverage are still running on d371573, with no failure so far. I did not run the three new stable-capture tests myself. Their fail-before claim rests on my reading of the 84f170e code and on your stash run. The live run's settle diff (3 removals, 1 unchanged) points to a baseline of 4 nodes or fewer, so it shows the 300 ms window and the latch on device. It does not show the non-tiny rootless-first-capture route this change fixes, which only unit fixtures cover so far. No conflicts. Both checks need to finish green on d371573 before merge, and a failure inside an iOS settle or interaction step would point at this diff.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 14, 2026
@thymikee

Copy link
Copy Markdown
Member Author

iOS Smoke lane at d371573: unanswered deep-link confirmation, rerun

Run 38564798395, job 115854626059, step "Run fixture-backed iOS simulator E2E smoke" failed at step 16, wait for the deep-link destination (5/5) (re-issue after observation-prevented miss): agent-device wait text Automation lab 15000 returned COMMAND_FAILED "app 'com.callstack.agentdevicelab' is not running" with reason: wait_capture_stalled, runnerErrorCode: APP_NOT_RUNNING, dispatched: no, readableCaptures: 0 of 10. The other three lanes were green.

What happened on the device. step-history.json from the ios-artifacts artifact: steps 0-8 pass, including the plain cold launch (open --relaunch, 7.8 s), the home wait and the home snapshot. Step 9, open --relaunch --launch-url agent-device-test-app:///automation?..., answers 0 in 2.8 s. The destination wait then times out on 1/5 (deadlineExceeded) and reports the app not running on 2/5 through 5/5. failed-step-16.png shows the reason: the simulator is on SpringBoard with an unanswered iOS "Open in "Agent Device Tester"?" confirmation. The app never launched, so no capture was ever readable — there was no tree to price a quiet window against.

Why this diff cannot reach it. The production changes are settle.ts and stable-capture.ts, plus three description strings (flag-definitions-action.ts, post-action-observation-grammar.ts, a doc comment in client-gesture.ts). runStableCaptureLoop, the only changed behaviour, has three non-test callers: settle.ts (the --settle path), selector-read.ts for wait stable, and post-action-observation.ts for the declared interaction commands (click/fill/longpress/hover/press/back/scroll). open declares no post-action observation, and the Apple launch-confirmation handshake that actually failed lives in packages/platform-apple (settleAppleOpen in open-policy.ts, launch-confirmation.ts), which this branch does not touch — git diff --name-only origin/main...HEAD | grep platform-apple is empty. The failing command is wait text, which polls captures on its own path; the suite has no --settle and no wait stable anywhere, so no changed code had run before step 16. And the changed code only ever chooses how long the loop waits for captures after an interaction — it cannot terminate an app or answer SpringBoard. dispatched: no on every poll of the failing step is consistent with that: nothing reached the interaction path.

This is the launch-confirmation race that settleAppleOpen documents in its own comment — "a read that found no prompt ... leaves the open as green as it was, with only the one extra runner command spent". Here the prompt appeared after the open had already answered green, which is also the same deep-link stage where the previous head of this PR failed with simctl openurl ETIMEDOUT (different signature, same handshake). Not main-broken: the lane is green at ba7730af0 (run 38552348030, this step success), and the same step passed at this layer's pre-rebase head 84f170e31 (run 38387528814) with this suite's deep-link code unchanged by this PR.

Rerunning the failed lane once: https://github.com/callstack/agent-device/actions/runs/38564798395 (attempt 2). I will report the result here.

@thymikee
thymikee added this pull request to stack #3615 October 14, 2026 15:17
@thymikee

Copy link
Copy Markdown
Member Author

Review follow-up: window helper folded in, Smoke lane green on rerun

Smoke at d371573 — not the diff, green on rerun. Run 38564798395 attempt 2 is success, step "Run fixture-backed iOS simulator E2E smoke" included. The evidence in the earlier comment holds: the app never launched (SpringBoard + unanswered "Open in "Agent Device Tester"?" in failed-step-16.png), the lane's own deep-link code is untouched by this branch, and platform-apple — which owns the launch-confirmation handshake that failed — is not in the diff at all. Main is green at ba7730af0 on this step and the step passed at this layer's pre-rebase head.

The baseQuietMs copy — applied. Agreed; folded into runStableCaptureLoop as requestedQuietMs/let quietMs with the re-price line at the capture that pays it, and the window comment moved to the loop that owns the state. createStableCaptureWindow had exactly one call site and one reader per side of it, so the helper was only naming the state. Net −15 lines, no new tests owed (the existing arming, pricing and reported-quietMs cases all still decide the same values); stable-capture, settle-default-quiet and settle are 62/62 before and after. Pushed as fd4c0c7cd — behaviour-neutral, but it does restart the lane as you noted. Local full gate (install / build / pnpm format / git diff --exit-code / check:affected --run --base origin/main) is running under the host lock and I'll report the result here.

The move-only extraction PR is out of scope for this layer. Splitting runStableCaptureLoop into its own file is a pure move with no behaviour claim to hold this PR's gate, and this layer's head is already at 667 gross lines of behaviour plus its regressions. Happy to pick it up as its own PR once this lands.

On the rootless-first-capture route being fixture-only. Agreed, and I don't think a device run can close that gap: the route needs the first post-action capture to come back without the viewport root while the baseline is above the tiny-tree count, which on a real Simulator is a race with the dismissal animation rather than something a scenario can ask for. The three cases pin it where it is observable, and each asserts waitedMs >= 1500 for the confirmation the fixture should have armed; at 84f170e31 they settle at 1004 / 802 / 502 ms because the first capture ruled the confirmation out for the whole run, which is the arming bug itself rather than a tree-content difference. What the iPhone 17 Pro run does prove on device is the window and the latch themselves, which is what the 300 ms default needed.

@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 8 files

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

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

Comment thread src/commands/interaction/runtime/settle.ts
Comment thread src/commands/interaction/runtime/settle-default-quiet.test.ts Outdated
Comment thread src/commands/interaction/runtime/stable-capture.ts Outdated
Comment thread src/commands/interaction/runtime/stable-capture.test.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

The default quiet window now comes from the capture it settles, and the delta after the earlier review at d371573 changes no behavior. This review covers fd4c0c7, and I found no code defects that block it.

Not blocking, and you can take or leave these: the new tests 'a dismissal confirmation is not dropped by a later empty projection' (stable-capture.test.ts:329) and the three 'settle arms the confirmation on the first rooted XCTest capture after ...' cases (:374) say they guard defects that the merge-base ba7730a did not have, because that loop already carried currentQuietMs as a monotonic max and used transitionBaseline ??=; please restate those comments as invariants of the re-priced loop, and drop the history narration from the broadTransitionConfirmation doc comment (stable-capture.ts, around :320-345).

On simplicity: the per-producer price is now one string compare, snapshot.producer === 'simulator-ax-bridge', in the platform-neutral loop (stable-capture.ts:67-71). capture-kit already keys producer facts on exhaustive Record<IosSnapshotProducer, ...> tables, such as IOS_SNAPSHOT_TRUNCATION_EVIDENCE in packages/capture-kit/src/ios-snapshot-acquisition.ts. Should the settle price live in a similar table, so that limrun-ios-tree, appium-source and any future producer must state their window instead of falling through to 500 ms? Also, quietSinceMs carries over when the loop re-prices, so a runner-to-bridge switch shortens the window during the wait. Is that intended, given that the comment at :122-126 says re-pricing is never a shorter wait?

These open threads still apply: the error-path quietMs (#3522 (comment)) and the missing shortened-wait assertion (#3522 (comment)). Two lower-priority comment-wording threads also still apply.

The Compatibility & Provenance check failed, and I could not read its failed-step log, so I cannot say if this PR caused it. The PR changes no exports, so the Fallow audit is the likely cause, but I did not confirm that. I did not run tests. I read the diff and the head code, and I did not check the rerun-green Smoke claim at d371573 against the run.

Before merge, please get the Compatibility & Provenance log and fix or attribute the failure, and the queued Smoke and Coverage lanes must also pass.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 14, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Update: head is now 9f1becd8a. pnpm check:affected --run passed locally: 526 files, 4012 tests. pnpm check:fallow --base origin/main is clean.

Compatibility & Provenance: cause and fix

Cause: Fallow audit reported runStableCaptureLoop at cognitive complexity 16, over the limit of 15. Re-pricing the window on each capture added a ?? in the loop body, and the private-AX recovery branch had a nested break/continue.

Fix: The per-capture pricing is now its own step, stableCaptureWindow: it computes the priced window, the window the confirmation requires, and the poll interval. Recovery and observation now feed a single step, which removes the nested branch. No threshold, baseline or allowlist was changed.

Cubic threads (all 4 replied and resolved)

  • Error-path quietMs: fixed. The loop no longer throws. A capture that throws ends the run with failure, and the result carries the window the run priced. Settle reports that window, and also does so when reading a finished outcome fails. The 500 ms default is reported only when no capture completed. New test: a capture that breaks mid-settle reports the window the run had priced.
  • Shortened wait was not asserted: fixed. The producer cases now use a non-broad fixture and assert the elapsed wait: under 500 ms for the bridge, at least 500 ms for the other producers. Changing the bridge entry to 500 fails 4 tests.
  • Gate comment (stable-capture.ts): reworded. The capture gate is re-asked until one capture passes it. That capture fixes the comparison baseline, and every later capture is compared against it.
  • Missing producer in fixtures (stable-capture.test.ts): the rationale now says these captures fall back to the 500 ms window, and that the assertions tell it apart from the 1.5 s floor.

Owner review

  • Take: test comments and doc comment as invariants. Done. The empty-projection test and the mid-animation cases now state the invariant the loop holds. The history narration (fix(settle): iOS broad-transition confirmation fires on tab switches and pushes and is coupled to the default quiet window #3470, the old requestedQuietMs bar) is gone from the broadTransitionConfirmation doc comment and from the floor constant's comment.
  • quietSinceMs carry-over vs "re-pricing is never a shorter wait": the comment was wrong; the code is right. The quiet span is elapsed time during which the tree did not change, whichever producer read it, and the capture that decides the verdict is the read the window prices. So a switch from runner to bridge during the wait does shorten the unconfirmed window. A confirmed broad replacement never drops below its 1.5 s floor. The comment now says this, and a new test pins it: the capture that decides the verdict prices the whole quiet span, even after a producer switch (runner, then bridge, settles in under 500 ms with quietMs: 300).
  • Exhaustive per-producer table: done, but in the settle module rather than capture-kit. DEFAULT_QUIET_MS_BY_PRODUCER satisfies Record<SnapshotProvenance['producer'], number>, so any new producer must state its window. It is keyed on every producer rather than IosSnapshotProducer, because the loop is platform-neutral and prices Android, HarmonyOS, macOS, Linux and web captures as well. It stays out of capture-kit because the price is a settle policy with one consumer: IOS_SNAPSHOT_TRUNCATION_EVIDENCE describes what an iOS acquisition observes, while this table decides how long settle waits. A capture with no producer keeps the 500 ms default.

#3570 has been restacked onto this head. Its own changes are unchanged (git range-diff shows only context), and it is still a draft.

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

Comment thread src/commands/interaction/runtime/stable-capture.ts
Comment thread src/commands/interaction/runtime/stable-capture.test.ts Outdated

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

Comment thread src/commands/interaction/runtime/stable-capture.test.ts Outdated
…ays it

`--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge
answers well inside it, so the loop spends a whole extra capture holding a window the tree has
already satisfied — and the window is paid twice per settled command: once as the settle's final
window, then again as the next command's baseline capture.

The loop now prices the window from the capture it performs rather than the one the action was
performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator
snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any
capture that declares no provenance — only the bridge carries the measurement. It re-prices on every
capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports
the window it ran on (`settle.quietMs`) so the response states which default it applied.

Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own
reads, and a stored ref frame or session tree can be several commands stale or served by another
producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest`
and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the
general default of its own.

Cadence follows the window in force, so the shorter default also samples on a grid that can measure
it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass,
which is what makes the re-pricing a single line rather than a fourth piece of loop state.

The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s
confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with
`requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one
in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off
for every default settle the bridge answers.
…one arms

The arming decision was made on the first capture and then stored as null for the rest of the
run, but two of its inputs belong to the capture, not to the run: viewport-root completeness and
the declared capture channel. A dismissal of anything larger than an alert or sheet needs both
before the confirmation may arm, so a mid-animation rootless or empty first read -- or one served
by another channel -- ruled the 1.5s floor out for a settle that owed it. Only the pre-action
projection is constant for the run, so it is the only input allowed to rule the confirmation out;
the per-capture half now re-asks until one capture arms.
The window helper had one call site and one reader per side of it, so the
loop holds the window directly: an unrequested window is re-priced from
each capture it performs, a requested one stays fixed. Behaviour is
unchanged; the priced value the result reports is the same window.
…ry path

- Price the default quiet window from an exhaustive per-producer table, so a new producer must
  state its window.
- Return capture failures from the stable-capture loop with the window the run priced, so a broken
  settle reports 300 ms after a bridge read instead of the general default.
- Extract the per-capture window step out of runStableCaptureLoop.
- Pin the shortened bridge wait on a non-broad fixture, and pin that the deciding capture prices the
  whole quiet span across a producer switch.
- State the confirmation gate and test rationale as invariants.
The stable-capture loop returns a capture failure instead of throwing it. `wait stable` now rethrows
that failure rather than mapping it to a stable timeout. The arming-input test comment now says a
mid-animation read that misses either input leaves arming open.
@thymikee
thymikee force-pushed the fix/3470-settle-quiet-per-capture-backend branch from c381817 to 40d8682 Compare October 14, 2026 17:15
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main — 40d868233

The branch was 19 commits behind main and lacked #3577's presentedSurface carriage, which #3570 reads. This rebases the existing commits onto origin/main (9f1becd8a, be8b0540a and c38181792 became 3be3ad5ad, c09f627d5 and 40d868233). The rebase was clean, with no content change to this layer.

pnpm check:affected --run --base origin/main passed on the stack head 927d2ce57 (#3570 on top of this): 534 test files, 4,058 tests, Fallow clean on 15 files. CI on 40d868233 is running.

This thread (5ad2f77b) now owns #3522 and #3570; worker 5 handed off.

🤖 Generated with Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

The PR is ready at 40d8682. The earlier points are now fixed: the settle catch path and the mid-settle crash test report the real quiet window, the default-quiet test uses the non-broad fixture with the right thresholds, and both stale comments are corrected, so the four Cubic threads (#3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment)) no longer apply and can be resolved.

Not blocking: failure?: { error: unknown } sits next to the settled and stalled booleans, so settle.ts and wait-stable.ts must each remember to check it, and selector-wait.ts re-declares the same field. Would a discriminated result (settled, timed-out, stalled, failed) make a missed failure branch a type error? You can take this or leave it.

Checks are still pending: Smoke Tests is queued, not failed. This delta changes runStableCaptureLoop, which only --settle and wait stable reach. The author says the fixture-backed smoke scenarios use neither, but I did not verify that, so a failure in an iOS settle or wait-stable step would point at this diff. The earlier Smoke failures at d371573 were a SpringBoard deep-link confirmation in platform-apple, which this diff does not touch. I did not run tests myself. The mutation results come from the author's report and my reading of the code. The rootless first-capture dismissal route is covered only by unit fixtures, and the earlier live run covers the 300 ms window and the latch, not this failure path. There are no conflicts. Before merge, Smoke Tests must finish green on 40d8682.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 14, 2026
@thymikee
thymikee merged commit 542fb77 into main Oct 14, 2026
19 checks passed
@thymikee
thymikee deleted the fix/3470-settle-quiet-per-capture-backend branch October 14, 2026 18:12
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-14 18:23 UTC

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.

1 participant