Skip to content

fix(android): actionable error when the snapshot helper is unavailable (#1284)#1285

Merged
thymikee merged 4 commits into
mainfrom
fix/1284-helper-missing-actionable-error
Jul 16, 2026
Merged

fix(android): actionable error when the snapshot helper is unavailable (#1284)#1285
thymikee merged 4 commits into
mainfrom
fix/1284-helper-missing-actionable-error

Conversation

@thymikee

Copy link
Copy Markdown
Member

Decision

#1284 decided (option a): keep the hard-fail introduced by #1217 (removing the stock-UIAutomator fallback), but make the error actionable. The old silent fallback produced a materially different capture (app-window-only, no systemui/IME, different ordering) and that silent difference caused repeated investigation confusion — failing loudly is intentional, not a bug.

This PR only changes error reporting for the two failure modes; it does not touch capture logic.

Before / after

Mode (a) — helper artifact missing on disk (dev build without pnpm build:android, or a broken packaged install):

Before:

Android snapshot helper is unavailable: the bundled helper artifact was not found
hint: For a source checkout, run pnpm build:android. For a packaged install, reinstall agent-device; the Android snapshot helper must ship with the package.

After:

Android snapshot helper is unavailable: the bundled helper artifact was not found
hint: Run `pnpm build:android` to build the full helper dist (android/snapshot-helper/dist: the .apk, .apk.idsig, .apk.sha256, and .manifest.json) for a source checkout. Packaged installs ship this dist automatically via the prepack script — if it is missing from a packaged install, reinstall agent-device.

Mode (b) — artifact present, device rejects the install (adb install rejection, OEM policy):

Before:

Android snapshot helper failed: Failed to install Android snapshot helper: Failure [INSTALL_FAILED_...]
hint: The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.

(or, for unrecognized adb output, the generic "Retry once... run agent-device doctor" fallback — with nothing calling out that the artifact itself was fine)

After:

Android snapshot helper failed: Failed to install Android snapshot helper: Failure [INSTALL_FAILED_...]
hint: The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause. This is a device-side install failure (adb rejection or OEM policy) — the helper artifact itself is present, this is not a missing/unbuilt build.

The underlying adb error was already surfaced in the message (via existing exec-failure enrichment); what was missing was an explicit signal that this is not the same failure class as a missing build artifact.

Implementation

The two modes were already on separate code paths (resolveAndroidSnapshotHelperArtifact's fs.access checks vs. the install/capture try/catch in captureAndroidUiHierarchyWithHelper), so no new detection logic was needed to distinguish them. The change:

  • src/platforms/android/snapshot-helper-install.ts: tags a nonzero-exit adb install result with androidSnapshotHelperInstallFailure: true in the thrown AppError's details.
  • src/platforms/android/snapshot.ts: androidSnapshotHelperUnavailableError (mode a) gets the richer hint; androidSnapshotHelperCaptureError (mode b) checks the new detail flag and appends the device-side framing while preserving any adb-specific classified hint (e.g. INSTALL_FAILED_UPDATE_INCOMPATIBLE, insufficient storage).

Tests

  • src/platforms/android/__tests__/snapshot-helper.test.ts: ensureAndroidSnapshotHelper tags a device-side install rejection distinctly from artifact resolution.
  • src/platforms/android/__tests__/snapshot.test.ts:
    • snapshotAndroid gives an actionable hint when the helper artifact is missing on disk (asserts pnpm build:android, prepack, and the full dist file set appear in the hint).
    • snapshotAndroid distinguishes a device-side install rejection from a missing build artifact (asserts the adb error and "device-side install failure" appear, and pnpm build:android does not).

Test plan

  • pnpm typecheck
  • pnpm exec oxlint --deny-warnings
  • pnpm format:check
  • pnpm check:affected --base origin/main --run (147/147 affected files pass; the only failures in the subsequent full-suite coverage run were client-metro.test.ts and runtime-hints.test.ts, both unrelated to this change and confirmed to pass cleanly in isolation — known flaky under CPU contention)

Links: #1284, #1217

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.7 MB 1.7 MB +877 B
JS gzip 557.3 kB 557.6 kB +302 B
npm tarball 672.9 kB 673.2 kB +280 B
npm unpacked 2.4 MB 2.4 MB +877 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.7 ms 30.4 ms +2.6 ms
CLI --help 60.4 ms 64.6 ms +4.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js +877 B +302 B

@thymikee

Copy link
Copy Markdown
Member Author

Review finding at beff38ff24f54de6c2cac5e783f9be0416abe9fe:

The missing-artifact hint describes the wrong required/package contents. It tells source users that a complete helper dist requires .apk, .apk.idsig, .apk.sha256, and .manifest.json, then says packaged installs ship this dist through prepack. However, package.json explicitly excludes android/snapshot-helper/dist/*.idsig from the npm package, and runtime artifact resolution only requires the manifest plus its referenced APK. The checksum is embedded in the manifest; the sidecar .sha256 is not read by this path.

Please make the recovery guidance describe the actual runtime contract, for example: run pnpm build:android in source checkouts to generate the bundled helper APK and manifest; reinstall packaged builds when either is absent. The test should assert those required artifacts rather than pinning .idsig as mandatory.

The new install-failure detail and device-side framing otherwise look correct and preserve classified ADB hints. This PR is still draft and iOS smoke was pending at review time.

thymikee added a commit that referenced this pull request Jul 16, 2026
… files

Review correction on #1285: resolveAndroidSnapshotHelperArtifact only
fs.access'es the versioned .manifest.json and the .apk it references —
the sha256 is a manifest field, and *.idsig is excluded from the npm
package by design. The hint no longer claims the full sidecar set is
required, and the test now rejects any future idsig claim.
@thymikee

Copy link
Copy Markdown
Member Author

Corrected in 426c975. Verified against resolveAndroidSnapshotHelperArtifact: the runtime fs.accesses exactly two files — android/snapshot-helper/dist/agent-device-android-snapshot-helper-<version>.manifest.json and the APK it references via assetName (fallback agent-device-android-snapshot-helper-<version>.apk). The sha256 is a field inside the manifest, not a required sidecar, and *.idsig is excluded from the npm package by design (!android/snapshot-helper/dist/*.idsig in package.json files), so a packaged install correctly never has it.

New hint:

Run pnpm build:android to build the helper dist for a source checkout — the runtime needs android/snapshot-helper/dist/agent-device-android-snapshot-helper-<version>.manifest.json and the .apk it references. Packaged installs ship these via the prepack script; if they are missing from a packaged install, reinstall agent-device.

The test now asserts .manifest.json and .apk in the hint and adds assert.doesNotMatch(hint, /idsig/) so the npm-excluded file can never be re-claimed as required. Gates rerun: typecheck / oxlint / format:check clean; check:affected affected stage 147/147; the only full-suite coverage failures were runtime-hints.test.ts (known flaky under CPU contention, passes in isolation).

@thymikee thymikee marked this pull request as ready for review July 16, 2026 15:12
@thymikee

Copy link
Copy Markdown
Member Author

Re-review at exact head 426c9752edd83b6ef3f09c22f5eed52b18b2c435:

  • P1 — the artifact-present install-failure distinction still misses a supported production path. ensureAndroidSnapshotHelper attaches androidSnapshotHelperInstallFailure only after its install call resolves with a nonzero result. An AndroidAdbProvider.install callback may instead reject with an enriched AppError carrying INSTALL_FAILED_* (the provider funnel explicitly supports this); that rejection bypasses the marker, and androidSnapshotHelperCaptureError emits the generic retry/doctor hint even though the artifact is present and installation failed. Tag/rethrow across the entire install phase while preserving the original code, message, hint, details, and cause. Add a regression where provider.install rejects and assert the public daemon snapshot error response reports the device-side install classification.
  • P2 — the capture rewrap can discard upstream diagnostic identity. androidSnapshotHelperCaptureError calls normalizeError, then copies only normalized.details into the new AppError; lifted diagnosticId and logPath are therefore lost. Preserve them per ADR 0010, or prove with a public-route test that request finalization replaces them without losing relevant upstream diagnostics.

The corrected missing-artifact guidance now accurately names the manifest plus referenced APK, and excluding .idsig is sound.

This branch also predates merged #1281, which changed the same helper/install/snapshot files. Although the merge is currently conflict-free and existing CI is green, that CI did not validate the combined tree; please update onto current main and rerun the relevant checks after addressing the findings. Not ready for ready-for-human.

@thymikee thymikee marked this pull request as draft July 16, 2026 15:36
thymikee added 3 commits July 16, 2026 17:36
#1284)

#1284 kept the hard-fail from #1217 (a silent stock-UIAutomator fallback
produced a materially different, app-window-only capture) but asked for
actionable hints on both failure modes:

- Artifact missing on disk: hint now names the exact `pnpm build:android`
  command, the full dist file set it produces, and notes packaged installs
  ship it via prepack.
- Artifact present but the device rejects the install (adb/OEM policy):
  the underlying adb error already surfaces in the message; the hint now
  explicitly frames this as a device-side failure distinct from a missing
  build artifact, tagged via a new androidSnapshotHelperInstallFailure
  detail set at the adb install call site.
… files

Review correction on #1285: resolveAndroidSnapshotHelperArtifact only
fs.access'es the versioned .manifest.json and the .apk it references —
the sha256 is a manifest field, and *.idsig is excluded from the npm
package by design. The hint no longer claims the full sidecar set is
required, and the test now rejects any future idsig claim.
…ty (#1285 review)

P1: the device-side install marker now covers the whole install phase.
ensureAndroidSnapshotHelper previously tagged only a resolved nonzero
install result; an AndroidAdbProvider.install rejection (enriched
INSTALL_FAILED_* AppError from the provider funnel) bypassed the marker
and fell back to the generic retry/doctor hint. Both paths now flow
through markAndroidSnapshotHelperInstallFailure, which mutates details
in place so the original code, message, hint, details, and cause all
survive. Regression covers the public daemon snapshot route with a real
request handler and an injected provider whose install rejects.

P2: androidSnapshotHelperCaptureError rewrapped through normalizeError,
which lifts diagnosticId/logPath out of details — the rewrap dropped
them (ADR 0010 violation). They are now reinstated into the rewrapped
error's details. Hint selection moved to a helper to keep the function
under the complexity gate.
@thymikee thymikee force-pushed the fix/1284-helper-missing-actionable-error branch from 426c975 to dba7393 Compare July 16, 2026 15:52
@thymikee

Copy link
Copy Markdown
Member Author

Both findings addressed at dba7393, on top of a rebase onto current main (1a1ef7c, includes #1281 — verified my diff file-by-file against the new base; the #1281 changes to rememberInstalledSnapshotHelper cache eviction and the helperSha256 capture option are intact, no overlap with this diff).

P1 — install-phase tagging now covers both paths. The entire install phase in ensureAndroidSnapshotHelper flows through a single markAndroidSnapshotHelperInstallFailure seam:

  • result path — a resolved nonzero install result is wrapped by androidAdbResultError and then tagged (as before);
  • rejection path — a rejected AndroidAdbProvider.install (e.g. the funnel-enriched INSTALL_FAILED_* AppError) is coerced via asAppError and tagged by mutating details in place, so the original code, message, funnel-classified hint, details (stderr/adbFailure), and cause chain all survive; the capture layer then appends the device-side framing to the preserved hint instead of replacing it.

P2 — diagnostic identity survives the rewrap. androidSnapshotHelperCaptureError now reinstates the normalizeError-lifted diagnosticId/logPath into the rewrapped error's details (liftedDiagnosticIdentity), per ADR 0010. Preserving was chosen over the proof-only option. Hint selection moved into androidSnapshotHelperCaptureHint to stay under the fallow complexity gate.

New tests:

  • request-router-android-snapshot-helper.test.ts :: snapshot reports a device-side install failure when the provider install rejects — public daemon route via a real createRequestHandler with an injected provider whose install rejects; asserts the wire error carries the classified adb hint + device-side framing, androidSnapshotHelperInstallFailure: true, and not the build-artifact hint.
  • snapshot-helper.test.ts :: ensureAndroidSnapshotHelper tags a rejected provider install without losing the enriched error — same-instance rethrow with marker, stderr, and funnel hint intact.
  • snapshot.test.ts :: snapshotAndroid preserves upstream diagnosticId and logPath through the capture rewrap.

Gates on the rebased tree: typecheck / oxlint / format:check / fallow clean; check:affected affected stage 148/148 files (1345 tests). The full-suite coverage stage flagged 8 files, all process-spawn/fs-heavy suites (runtime-hints, client-metro, install-source, artifact-materialization, session-replay-vars, apple index/runner-client/screenshot-density) that overlapped concurrent local vitest runs; all 8 pass in isolation (292/292). Leaving as draft per your call.

@thymikee

Copy link
Copy Markdown
Member Author

Current-head re-review (dba7393b3): the provider-rejection classification and public daemon regression now cover the original install-failure gap, and diagnosticId/logPath preservation is fixed.

One ADR 0010 wire-contract blocker remains: androidSnapshotHelperCaptureError() normalizes the caught error, which hoists and strips details.retriable and details.supportedOn, but liftedDiagnosticIdentity() restores only diagnosticId/logPath. A provider install rejection classified as transient (for example device_offline/connection_dropped) therefore loses its structured retry signal on the public daemon response.

Please preserve all lifted wire fields—at least retriable and supportedOn—when rewrapping, and add a public request-router assertion such as retriable: true. The current public regression checks only message/hint/marker.

…ap (#1285 review)

normalizeError hoists hint, diagnosticId, logPath, retriable, and
supportedOn out of details (ADR 0010); the capture rewrap restored only
diagnosticId/logPath, so a transient-classified install rejection (e.g.
connection_dropped) lost its structured retriable signal on the public
daemon error. liftedDiagnosticIdentity is generalized to liftedWireFields
covering the complete hoisted set (hint stays owned by the capture hint
selector). Public route regression: a retriable provider install
rejection keeps error.retriable === true on the daemon snapshot response.
@thymikee

Copy link
Copy Markdown
Member Author

Addressed at 1089e7c.

Complete lifted-field restore. liftedDiagnosticIdentity is generalized to liftedWireFields, restoring every field normalizeError hoists out of details — the complete set stripDiagnosticMeta removes in src/kernel/errors.ts: diagnosticId, logPath, retriable, supportedOn. The fifth hoisted field, hint, is deliberately not restored verbatim because androidSnapshotHelperCaptureHint owns it (preserving the upstream hint and appending classification framing). The function comment cites ADR 0010 and names the source of truth, so a new hoisted field can't silently regress this a third time without the comment pointing at the list to extend.

Extended public regression (same file, real createRequestHandler route): request-router-android-snapshot-helper.test.ts :: snapshot keeps the transient retry signal when the provider install rejection is retriable — the provider install rejects with stderr matching the funnel's connection_dropped family (classified retriable: true), and the test asserts response.error.retriable === true on the public daemon snapshot error alongside the message, preserved classified hint + device-side framing, and the androidSnapshotHelperInstallFailure marker.

Gates: typecheck / oxlint / format:check / fallow clean. check:affected vitest-related stage: 145/148 files; the 3 failing files (provider-scenarios/doctor, daemon-entrypoint, session-replay-vars) are port/process-spawn suites unrelated to this change and pass 81/81 in isolation — the failing set differs from the previous round's, consistent with contention flakiness (another agent worktree was running its own full gate concurrently). All snapshot/helper/route suites green in every round. Staying in draft.

@thymikee

Copy link
Copy Markdown
Member Author

Current-head re-review (1089e7c06): code review is clean. The ADR 0010 wire-field gap is fixed: the snapshot-helper rewrap now restores diagnosticId, logPath, retriable, and supportedOn after normalization, while the capture layer intentionally owns the composed hint. The new public request-router regression drives a classified connection_dropped provider install rejection through the real daemon snapshot route and pins top-level retriable: true; reverting the restore fails it. The PR is mergeable, all current CI checks are green, and the draft state does not change the clean code verdict.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 16, 2026
@thymikee thymikee marked this pull request as ready for review July 16, 2026 17:12
@thymikee thymikee merged commit 991e723 into main Jul 16, 2026
22 checks passed
@thymikee thymikee deleted the fix/1284-helper-missing-actionable-error branch July 16, 2026 17:16
@github-actions

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