Skip to content

fix(replay): publish a full-cover system overlay's targets in divergence screen.refs#1318

Merged
thymikee merged 5 commits into
mainfrom
claude/brave-hellman-535817
Jul 17, 2026
Merged

fix(replay): publish a full-cover system overlay's targets in divergence screen.refs#1318
thymikee merged 5 commits into
mainfrom
claude/brave-hellman-535817

Conversation

@thymikee

@thymikee thymikee commented Jul 17, 2026

Copy link
Copy Markdown
Member

Refs #1312 (closed as completed by #1301 — that close was incomplete: #1301 fixed the capture, but this issue's headline symptom, 0 refs for a full-cover overlay, survived it for a different reason; corrected diagnosis posted there).
Splits out #1319 (--settle may be blind to an expanded shade for the same run-rule reason — untested, plausibly desirable).

The bug

With a foreground app open, raise a persistent full-cover quick-settings shade (adb shell cmd statusbar expand-settings) and trigger a replay divergence. The divergence screen comes back available with 0 refs and no suggestions — while the capture plainly holds 95 systemui nodes, 23 of them hittable quick-settings tiles.

Root cause

selectDivergenceScreenRefNodes drops collectSettleChromeRefs nodes before isForeignOverlayDismissTarget can rank them, so the overlay ranking never runs.

collectAndroidSystemChromeRunIndexes condemns an entire contiguous same-package run when any node in it carries a status/nav marker. An expanded shade is a single systemui run — proven against a live capture:

  • run root legacy_window_root, 95 nodes, one run
  • exactly 4 nodes carry markers: clock, mobile_combo, mobile_signal, wifi_signal — status-bar icons the expanded shade hosts inside its own window
  • those 4 condemn all 95, taking all 23 hittable qs_tile_* targets with them

The run rule assumes the status bar is its own window. True when the shade is collapsed; false when it is expanded.

Why this is an invariant violation, not just a missed goal

Since #1301, a plain snapshot of that same surface returns the tiles (the systemSurfaceOnly carve-out). So the divergence had become strictly narrower than snapshot — precisely what captureDivergenceObservation documents and the ADR 0012 decision 4 amendment forbids: the chrome filter must stay a FILTER, "never as a narrower scoping".

Why the fix is divergence-local, not in the shared run rule

Settle and divergence genuinely disagree about these 95 nodes, and both are right. Settle must keep stripping the shade run: the 4 markers are literally clock/signal/wifi — the canonical churn settle exists to ignore — and sparing runs that hold actionable content would let a ticking clock hold settle awake. The divergence layer owns the violated contract, so that is the layer fixed. The shared classifier in snapshot-chrome.ts is untouched, so --settle behavior is unchanged by construction.

Chrome exclusion now falls back to hittable chrome nodes when it would otherwise empty the pool — mirroring the covered fallback already in the same function ("a report broken by construction").

The gate is hittability, not label presence, and both real captures back that:

clock capture hittable? outcome
collapsed status bar (7:03) IME fixture no excluded — a chrome-only screen still publishes nothing
expanded shade (9:56, 339×350) QS fixture yes surfaces — a genuinely tappable target

Without that gate, a bare "7:03" would be published as the whole screen whenever an app tree yielded no meaningful targets.

Verification

Live on emulator-5556 (Pixel 9 Pro XL API 37, deskclock):

  • before: screen.refs: 0
  • after: screen.refs: 20 (cap, truncated: true) — e14:seekbar "Display brightness" + 19 hittable tiles
  • control (no shade) still replays clean

The live refs match the fixture-driven unit test exactly (same count, order, labels). ANDROID_QS_SHADE_CAPTURE_RAW_NODES is a real 138-node --raw capture generated mechanically from the device, not hand-authored ids — the walk reproduces the live tree node-for-node (95 nodes / 23 hittable). The regression test fails if the fix is reverted.

225 test files / 1931 tests, lint and typecheck all pass.

Notes for reviewers

…nce screen.refs

A fully expanded quick-settings shade left the divergence `screen` available but
empty: 95 captured systemui nodes, 0 refs, no suggestions.

`selectDivergenceScreenRefNodes` drops `collectSettleChromeRefs` nodes before
`isForeignOverlayDismissTarget` can rank them, and the run-level chrome rule
condemns a whole contiguous same-package run when any node in it carries a
status/nav marker. An expanded shade is a SINGLE systemui run: the 4 status-bar
icons it hosts (clock, mobile_combo, mobile_signal, wifi_signal) condemn all 95
nodes, the 23 hittable qs_tile targets included. The run rule assumes the status
bar is its own window — true collapsed, false expanded.

Since #1301 a plain `snapshot` of that same surface returns the tiles (the
`systemSurfaceOnly` carve-out), so the divergence had become strictly NARROWER
than `snapshot` — the invariant `captureDivergenceObservation` documents and the
ADR 0012 decision 4 amendment forbids: the chrome filter must stay a FILTER, never
a narrower scoping.

Fixed in the divergence layer, not in the shared run rule: settle and divergence
genuinely disagree about these nodes and both are right. Settle must keep
stripping the shade run — the markers are literally clock/signal/wifi, the churn
settle exists to ignore, and sparing runs that hold actionable content would let a
ticking clock hold settle awake. The divergence layer owns the violated contract.

Chrome exclusion now falls back to hittable chrome nodes when it would otherwise
empty the pool, mirroring the existing `covered` fallback. The gate is
hittability, not label presence, and both real captures back that: the collapsed
status-bar clock is labeled "7:03" but not hittable (so a chrome-only screen still
publishes nothing), while the expanded shade's clock is hittable.

Live-verified on emulator-5556 (Pixel 9 Pro XL API 37): same repro 0 -> 20 refs
(cap, truncated) — the brightness slider plus 19 hittable tiles, matching the
fixture-driven test exactly. `ANDROID_QS_SHADE_CAPTURE_RAW_NODES` is a real
138-node --raw capture, not hand-authored ids.
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.8 MB 1.8 MB +4.6 kB
JS gzip 566.6 kB 568.1 kB +1.5 kB
npm tarball 681.4 kB 682.8 kB +1.5 kB
npm unpacked 2.4 MB 2.4 MB +4.6 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.9 ms 28.3 ms -0.6 ms
CLI --help 59.1 ms 59.1 ms -0.0 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js +4.6 kB +1.5 kB
dist/src/session.js +51 B +11 B

The fallback fires only when chrome exclusion would empty the pool, mirroring the
`covered` fallback right below it. A marker-bearing overlay over PARTIALLY visible
app content keeps its tiles condemned — the pool is non-empty, so no fallback.
That is deliberate (the full-cover case is the one that strands an agent), but it
was implicit; stating it saves the next investigator a live session.
@thymikee

Copy link
Copy Markdown
Member Author

Code review is clean at 519af6e3. The divergence-local fallback restores actionable refs for a full-cover Quick Settings surface without changing settle chrome classification, preserves the existing covered/ranking/cap behavior, and derives both published and authorized refs from the same selection. The walked real-capture regression is non-vacuous and fails on the prior implementation. CI is still in progress; no code blocker found.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 17, 2026
thymikee added 3 commits July 17, 2026 10:35
The fixture module had grown to 1080 lines, of which ~990 were two inlined
device-capture literals — the walkers and their docs were buried under the data
they operate on, and any future capture would bury them further.

Capture fixtures are archived device trees: DATA to be regenerated from a device,
not code to be hand-edited. They now live in `.json` beside the module and load
via `fs` + `import.meta.url` — the existing `test/output-economy` baseline
pattern, which needs no `resolveJsonModule` or import-attributes support to
typecheck, and keeps the trees out of the bundle.

Mechanically extracted from the current exports and verified byte-identical
(`JSON.stringify` before/after match for both fixtures), so this is a pure move:
no fixture data changed. Module drops 1080 -> 109 lines. The leg-E regression test
remains revert-sensitive.
Reading the fixtures with `fs` + `JSON.parse(...) as RawSnapshotNode[]` bought
nothing over a static import and gave up the one thing that matters for a typed
data fixture: the cast asserts the shape away unchecked, so a fixture that drifts
from `RawSnapshotNode` compiles fine and only surfaces as a confusing test
failure — or silently passes.

`resolveJsonModule` + `import ... with { type: 'json' }` structurally checks each
tree against `RawSnapshotNode[]` at typecheck time instead. Verified: corrupting a
fixture (`hittable: "yes-please"`, a stray field) now fails `tsc` at the export,
where the `fs` version accepted it. Also drops the reader helper and the runtime
read.

The `test/output-economy` precedent I first copied reads baselines through `fs`
for a different reason — those are runtime-compared artifacts, not statically
typed fixtures — so it did not apply here.

tsconfig gains one option; `noEmit` is already set, so it has no output-layout
effect. Fixture data unchanged (byte-identical), leg-E test still
revert-sensitive, full tooling + 1931 tests green.
`hittableChromeCandidates(nodes)` did not filter chrome — it returned every
hittable node, and was only "chrome candidates" by virtue of its one call site.
The name lied, and a chunk of the 15-line comment above it existed to cover the
gap between the name and the behaviour: exactly the case where the comment is
propping up the code.

Inlined into the same `x.length > 0 ? x : y` idiom the `covered` fallback two
lines below already uses, so the two narrowings now read in parallel. The boundary
that comment spelled out — the fallback fires ONLY when exclusion empties the pool,
so a partial-cover overlay keeps its tiles condemned — is now the ternary itself
rather than a paragraph asserting it.

What survives is the part the code genuinely cannot show: WHY a chrome-only screen
must still publish (the never-narrower-than-`snapshot` contract) and why hittable
is the discriminator. 6 lines, against the 8-line `covered` comment beside it.

No behaviour change: same node set, fixtures untouched, leg-E test still
revert-sensitive, 1931 tests + full tooling green.
@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 17, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Re-review is clean at 356505da. Moving the archived captures to typed JSON fixtures preserves the real-tree regression while removing the oversized TypeScript literal; resolveJsonModule is limited to build/type resolution and the test-only imports remain excluded from production library output. The inlined chrome fallback is behavior-equivalent and retains the only-when-nonChrome-is-empty boundary. All 23 checks are green; no remaining blocker found.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 17, 2026
@thymikee
thymikee merged commit a6711a4 into main Jul 17, 2026
23 checks passed
@thymikee
thymikee deleted the claude/brave-hellman-535817 branch July 17, 2026 09:35
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-17 09:36 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