Skip to content

Extract snapshot policy from daemon to host-side facet (#1983) - #2014

Open
thymikee wants to merge 2 commits into
mainfrom
claude/trusting-archimedes-0uljz6
Open

Extract snapshot policy from daemon to host-side facet (#1983)#2014
thymikee wants to merge 2 commits into
mainfrom
claude/trusting-archimedes-0uljz6

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

Refactors snapshot-related policies out of the daemon layer into a host-side src/snapshot/ facet, establishing a clean separation of concerns where policy logic is testable independently of daemon assembly. This completes Wave 4 debt tracked by #1983.

Key Changes

  • Snapshot freshness policy: Moved Android staleness classification, retry logic, and thresholds from src/daemon/android-snapshot-freshness.ts to src/snapshot/snapshot-freshness/. The recovery loop is now parameterized by a classifier and retry schedule, making it reusable across backends.

  • Snapshot timeout policy: Extracted timeout error recognition from daemon-specific code into src/snapshot/snapshot-timeout-policy.ts, which identifies accessibility-timeout failures through both helper error types and daemon hint text.

  • Screenshot overlay policy: Moved Android-specific overlay source classification and rect balancing from src/daemon/screenshot-overlay-android.ts to src/snapshot/screenshot-overlay/android.ts.

  • Snapshot timeout evidence contract: Created @agent-device/contracts/snapshot-timeout-evidence.ts with a discriminated union type and builder functions to ensure type-safe evidence construction across the daemon/host boundary.

  • Session binding layer: Created src/daemon/session-snapshot-freshness.ts to handle daemon-specific concerns: reading/retiring the freshness window from SessionState, choosing comparison baselines from session snapshot lineage, and running the recovery loop with Android policy bound to it.

  • Test reorganization: Moved snapshot policy tests to src/snapshot/__tests__/ where they can run without daemon infrastructure, while keeping session-specific tests in src/daemon/__tests__/.

Implementation Details

  • The freshness window type is now platform-agnostic (SnapshotFreshnessWindow), allowing other backends with async hierarchy dumps to reuse the recovery loop with their own thresholds.

  • Android overlay policy is now testable independently via isAndroidUnlabeledClickableSource() and resolveAndroidOverlaySourceRect() functions.

  • Layering boundary tests updated to enforce that src/snapshot/ cannot import from src/daemon/, ensuring policies remain testable without session state.

  • Constants like ANDROID_FRESHNESS_WINDOW_MS are now exported from the policy modules rather than hidden in daemon code.

https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R

#2005 established the presentation ownership boundary and moved the iOS
presentation policies out of `src/daemon/`. It left the three remaining Wave 4
policies behind their existing daemon adapters. This closes that gap, so
`src/snapshot/` owns host-side snapshot policy generally rather than
presentation alone.

Freshness recovery: the window vocabulary, the Android staleness classification
and its thresholds, and the retry loop move to `src/snapshot/snapshot-freshness/`.
The loop is parameterized by a classifier and a retry schedule, so how long a
backend may lag behind a real transition is a policy input rather than a
constant the loop owns. `src/daemon/session-snapshot-freshness.ts` keeps only
what needs a session — reading and retiring the window on store-owned
`SessionState`, and choosing the comparison baseline from snapshot lineage — and
remains the declared R7 owner of `androidSnapshotFreshness`. The two call sites
#1739 named as the Wave 5 blockers, `selector-capture-runtime.ts` and
`deferred-interaction-outcome.ts`, now reach freshness through the seam.

Timeout evidence: whether a failure is the accessibility-timeout shape becomes a
policy in `src/snapshot/snapshot-timeout-policy.ts`. The published
`details.androidSnapshotTimeoutScreenshot` payload becomes vocabulary in
`@agent-device/contracts/snapshot-timeout-evidence`, built through constructors
so an assembly site cannot publish a fifth, undeclared arm. It gets its own
subpath rather than riding the shared capture facade, which keeps it out of the
CLI cold-start closure. Typed details, diagnostics and screenshot evidence are
unchanged.

Screenshot-overlay policy: which Android nodes earn an overlay ref, and what
rectangle an overlay covers, move to `src/snapshot/screenshot-overlay/`. The
daemon keeps approved artifact and ref assembly only — ranking, projection to
screenshot pixels, drawing and PNG IO.

The boundary test generalizes from the presentation subtree to the whole facet:
nothing under `src/snapshot/` may import `src/daemon/`. It gains a positive
control, because a filter that stopped matching would look identical to a
boundary being obeyed.

The residual call sites #1983 also named are audited and deliberately left in
place. `direct-ios-selector.ts` carries no presentation policy; its two pure
exports are selector derivation and ADR 0011 delegation-on-error, whose owner
would be the selector pipeline governed by R19, not this facet. ADR 0004 records
the finding so it does not have to be re-derived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.41 MB 2.41 MB -1.5 kB
JS gzip 807.0 kB 806.6 kB -404 B
npm tarball 932.0 kB 931.6 kB -442 B
npm unpacked 3.23 MB 3.23 MB -1.7 kB

npm unpacked components

Component Base Current Diff
JS / dist source 2.56 MB 2.56 MB -1.5 kB
Apple runner source/project 570.6 kB 570.6 kB 0 B
macOS helper source 54.5 kB 54.5 kB 0 B
Android helper artifacts 0 B 0 B 0 B
Other package files 45.1 kB 44.9 kB -207 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.9 ms 28.8 ms +0.9 ms
CLI --help 83.3 ms 82.8 ms -0.5 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/session-store.js -1.8 kB -515 B
dist/src/session-snapshot.js +209 B +68 B

Top changed packed files

Packed file Base Current Diff
dist/src/session-store.js 48.2 kB 46.4 kB -1.8 kB
dist/src/session-snapshot.js 38.6 kB 38.8 kB +209 B
package.json 18.8 kB 18.6 kB -207 B
dist/src/snapshot-runtime.js 8.3 kB 8.5 kB +148 B
dist/src/selector-runtime.js 22.1 kB 22.1 kB -31 B
dist/src/screenshot-runtime.js 14.1 kB 14.1 kB +6 B

Three findings from an adversarial pass over bc95d7f, all in the new seams.

`SnapshotFreshnessRetrySchedule.deadlineMs` was an absolute epoch instant named
almost identically to the duration constant `ANDROID_FRESHNESS_RETRY_DEADLINE_MS`
that feeds it. A backend binding the loop with the duration instead of
`markedAt + duration` type-checked, drove `remainingMs` hugely negative, and
silently ran zero retries with no annotation. Renamed to `retryUntilMs` — the
pre-refactor local's name — and the doc now says which one it is. The recovery
loop also gains direct tests it never had: the trustworthy, recovered and
still-suspicious paths, plus an already-expired deadline that pins the budget to
the action rather than to whenever the first capture returned, which is the shape
the mis-binding would have taken.

Two stale doc references from earlier drafts of the same commit: the timeout
assembly claimed its evidence shape lives in `@agent-device/contracts/capture`,
which is where it deliberately does NOT live — following that comment would
re-home the type into the shared facade and reintroduce the cold-start closure
cost the dedicated subpath exists to avoid. And the freshness window doc cited
`SnapshotFreshnessPolicy`, a type removed before commit for being unused; the
real seam is the loop's `classify` callback.

No production behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R
@thymikee

Copy link
Copy Markdown
Member Author

Changes requested at aaf35801be0698c45051e5aac4c14098d5471784:

  1. src/snapshot/snapshot-freshness/recovery.ts accepts an unbranded absolute retryUntilMs, and its own comment admits that passing a duration type-checks and silently disables retries. Make the recovery owner derive the deadline from window.markedAt plus a retryBudgetMs (or use a distinct deadline type), and test that invariant.
  2. src/snapshot/snapshot-timeout-policy.ts recognizes timeout behavior from daemon hint/error text and helper.message. This violates the repository rule to key behavior on typed reasons/details. Normalize a typed Android snapshot-timeout reason at the platform boundary and consume that in the facet; replace the tests that approve message sniffing with producer-to-daemon coverage.
  3. SnapshotTimeoutEvidence still permits contradictory states such as annotated=true with zero refs. Encode empty/non-empty session-ref arms in the exported union (for example a non-empty tuple) so callers cannot assemble an invalid claim.

Coordinator local evidence at the exact head:

  • pnpm install --frozen-lockfile, pnpm build, and pnpm build:android passed in an isolated detached worktree.
  • iOS Simulator: pnpm ad open settings --platform ios --udid F7D6F9A4-4FCC-4DD7-AC0B-3280C9319CB9 --session sentinel-2014-ios --state-dir /private/tmp/agent-device-2014-state --json, then pnpm ad snapshot ... -i -c -d 4 --json both passed; snapshot quality was healthy with backend tree.
  • Android Emulator: opened Settings on isolated emulator-5556, then pnpm ad snapshot --platform android --state-dir /private/tmp/agent-device-2014-android-state -i -c -d 4 --json passed with androidSnapshot.backend=android-helper, helper 0.20.10, API 2. Clicking the Settings search action succeeded; an immediate depth-limited interactive snapshot correctly warned that filtering hid all 58 raw nodes, and an unrestricted follow-up returned the new Search settings route with 28 nodes and healthy android-helper quality.
  • Both sessions and both coordinator-started emulators were closed; the detached worktree is being removed.

The basic host assembly works on both local lanes, but it does not clear the typed-policy findings above. Do not mark ready on this head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants