From defd554a90b9add9a4537ac9893d48a70c7c1d04 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Fri, 23 Oct 2026 22:22:39 -0400 Subject: [PATCH 1/7] fix(android): bound the dumpsys reads behind dialog and foreground checks The blocking-dialog and foreground observations ran `adb shell dumpsys` with no timeout before and after every Android interaction, so a hung read waited for the client envelope and reset the daemon. Each dump read now carries a 10 s bound. A timed-out read counts as unanswered, the same `unknown` a read that printed nothing produces, so a slow probe still never refuses the command; the reader then skips its remaining variants, so one observation costs at most one bound. This unblocks the longpress envelope ledger in #3326, which cannot prove an envelope while these reads are unbounded. Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/android-observation.ts | 2 +- .../platform-android/src/observation.test.ts | 68 +++++++++++++++++++ packages/platform-android/src/observation.ts | 30 ++++++-- 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/contracts/src/android-observation.ts b/packages/contracts/src/android-observation.ts index 82080d1ce9..b267d9f3b1 100644 --- a/packages/contracts/src/android-observation.ts +++ b/packages/contracts/src/android-observation.ts @@ -27,7 +27,7 @@ export type AndroidObservationHost = Readonly<{ runAdb( device: DeviceInfo, args: readonly string[], - options?: Readonly<{ allowFailure?: boolean }>, + options?: Readonly<{ allowFailure?: boolean; timeoutMs?: number }>, ): Promise; readSnapshotNodes(device: DeviceInfo): Promise; openApp(device: DeviceInfo, appBundleId: string): Promise; diff --git a/packages/platform-android/src/observation.test.ts b/packages/platform-android/src/observation.test.ts index 0a0ab3cb91..ea6441ae35 100644 --- a/packages/platform-android/src/observation.test.ts +++ b/packages/platform-android/src/observation.test.ts @@ -1,6 +1,7 @@ import { expect, test, vi } from 'vitest'; import type { AndroidObservationHost } from '@agent-device/contracts/android-observation'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; import { createAndroidObservationAdapter } from './observation.ts'; const device = { @@ -98,3 +99,70 @@ test('a transient empty dump never demotes a variant that previously answered', expect(calls[thirdReadStart]).toBe('shell dumpsys window windows'); }); + +function timedOutAdb(args: readonly string[]): AppError { + return new AppError('COMMAND_FAILED', 'adb timed out', { cmd: 'adb', args, timeoutMs: 1 }); +} + +test('bounds every dump read the foreground question makes', async () => { + const host = hostFor(new Map()); + const observer = createAndroidObservationAdapter(host); + + await observer.readAppState({ ...device, id: 'emulator-bounded' }); + + const calls = vi.mocked(host.runAdb).mock.calls; + expect(calls.map(([, args]) => args.join(' '))).toEqual([ + 'shell dumpsys window windows', + 'shell dumpsys window', + 'shell dumpsys activity activities', + 'shell dumpsys activity', + ]); + for (const [, , options] of calls) { + expect(options).toEqual({ allowFailure: true, timeoutMs: expect.any(Number) }); + expect(options?.timeoutMs).toBeGreaterThan(0); + } +}); + +test.each([ + { + question: 'foreground', + ask: (observer: ReturnType, target: DeviceInfo) => + observer.readAppState(target), + unanswered: {}, + }, + { + question: 'blocking dialog', + ask: (observer: ReturnType, target: DeviceInfo) => + observer.readBlockingDialog(target), + unanswered: { status: 'unknown' }, + }, +])( + 'a timed-out dump leaves the $question question unanswered and reads no further variant', + async ({ question, ask, unanswered }) => { + const host: AndroidObservationHost = { + ...hostFor(new Map()), + runAdb: vi.fn(async (_device, args) => { + throw timedOutAdb(args); + }), + }; + + await expect( + ask(createAndroidObservationAdapter(host), { ...device, id: `emulator-hung-${question}` }), + ).resolves.toEqual(unanswered); + expect(host.runAdb).toHaveBeenCalledTimes(1); + }, +); + +test('a dump that fails without timing out still fails the observation', async () => { + const failure = new AppError('COMMAND_FAILED', 'adb: device offline', { cmd: 'adb' }); + const host: AndroidObservationHost = { + ...hostFor(new Map()), + runAdb: vi.fn(async () => { + throw failure; + }), + }; + + await expect( + createAndroidObservationAdapter(host).readAppState({ ...device, id: 'emulator-offline' }), + ).rejects.toBe(failure); +}); diff --git a/packages/platform-android/src/observation.ts b/packages/platform-android/src/observation.ts index 8a421e085c..442fd9311d 100644 --- a/packages/platform-android/src/observation.ts +++ b/packages/platform-android/src/observation.ts @@ -5,6 +5,7 @@ import type { } from '@agent-device/contracts/android-observation'; import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { isCommandTimeoutError } from '@agent-device/host-kit/command'; import { deviceShellArgv } from '@agent-device/kernel/device-shell'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -32,6 +33,8 @@ const PERMISSION_PACKAGES = new Set([ 'com.google.android.packageinstaller', ]); +const DUMP_READ_TIMEOUT_MS = 10_000; + type Question = 'foreground' | 'blockingDialog'; type DumpReader = (words: readonly string[]) => Promise; const everAnswered = new Map>(); @@ -79,18 +82,35 @@ export function createAndroidObservationAdapter( }); } +/** + * A dump that times out is an unanswered read, like one that printed nothing: the observation + * reports `unknown` and never refuses the command. After one timeout the device is not answering, + * so the reader skips its remaining variants and an observation costs at most one bound. + */ function createDumpReader(host: AndroidObservationHost, device: DeviceInfo): DumpReader { const dumps = new Map>(); + let timedOut = false; return (words) => { + if (timedOut) return Promise.resolve(''); const key = words.join(' '); const pending = dumps.get(key) ?? host - .runAdb(device, deviceShellArgv('adb', 'shell', words), { allowFailure: true }) - .then((result) => { - recordSections(device, key, result.stdout); - return result.stdout; - }); + .runAdb(device, deviceShellArgv('adb', 'shell', words), { + allowFailure: true, + timeoutMs: DUMP_READ_TIMEOUT_MS, + }) + .then( + (result) => { + recordSections(device, key, result.stdout); + return result.stdout; + }, + (error: unknown) => { + if (!isCommandTimeoutError(error)) throw error; + timedOut = true; + return ''; + }, + ); dumps.set(key, pending); return pending; }; From 3f9b9cf318ca5a1ffb0a5b1e52e7a894f0f201bf Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Fri, 23 Oct 2026 22:30:18 -0400 Subject: [PATCH 2/7] fix(timeouts): prove the longpress request envelope with a worst-case ledger The longpress envelope (210 s) rested on a hand sum that nothing checked. A provider-integration ledger test now drives the Android cold-helper route at the maximum hold through the daemon with a fake adb provider that charges each call its own timeout on a virtual clock. The slowest branch (checksum mismatch, an install refused as INSTALL_FAILED_UPDATE_INCOMPATIBLE, uninstall, reinstall, one-shot hold) costs 279,990 ms; with the 30 s daemon-result margin the envelope must be at least 309,990 ms, so it rises to 310 s and its comment points at the test. The 120 s hold cap becomes LONG_PRESS_DURATION_MAX_MS, read by both the validator and the test. Closes #3326 Co-Authored-By: Claude Opus 5.5 --- packages/command-registry/src/registry.ts | 8 +- packages/contracts/src/gesture-plan-types.ts | 1 + .../command-descriptor-timeout-policy.test.ts | 8 +- src/commands/interaction/runtime/gestures.ts | 7 +- .../android-longpress-ledger.test.ts | 201 ++++++++++++++++++ 5 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 test/integration/provider-scenarios/android-longpress-ledger.test.ts diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index bd4066428f..025313e42a 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -1298,10 +1298,10 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', timeoutPolicy: { ...SETTLE_FLAG_PRESERVE_DAEMON_TIMEOUT_POLICY, - // Android's cold path may inspect/install the helper, hand off a running - // snapshot helper, hold for 120 seconds, then use 15 seconds of helper - // completion overhead. Keep that complete route inside the envelope. - envelopeMs: 210_000, + // Covers the Android cold-helper worst case at the maximum hold plus the + // daemon-result margin. Proven by the ledger test: + // test/integration/provider-scenarios/android-longpress-ledger.test.ts. + envelopeMs: 310_000, }, postActionObservation: postActionObservation('longpress'), batchable: true, diff --git a/packages/contracts/src/gesture-plan-types.ts b/packages/contracts/src/gesture-plan-types.ts index 34501a0003..ee3d5183b2 100644 --- a/packages/contracts/src/gesture-plan-types.ts +++ b/packages/contracts/src/gesture-plan-types.ts @@ -15,6 +15,7 @@ export type GestureContactCount = 1 | 2 | 3 | 4 | 5; export const GESTURE_DURATION_MIN_MS = 16; export const GESTURE_DURATION_MAX_MS = 10_000; +export const LONG_PRESS_DURATION_MAX_MS = 120_000; export const DEFAULT_DRAG_SOURCE_HOLD_MS = 800; export const DEFAULT_DRAG_MOVE_MS = 500; export const DEFAULT_DRAG_DESTINATION_HOLD_MS = 0; diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index 9b43dd4f4f..3fac0b8531 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -149,7 +149,9 @@ test('request envelopes deviating from the default are bounded, reviewed sets', reinstall: 180_000, uninstall: 180_000, install_source: 180_000, - longpress: 210_000, + // longpress: proven by the worst-case ledger test in + // test/integration/provider-scenarios/android-longpress-ledger.test.ts. + longpress: 310_000, // fold: proven by the worst-case ledger test in // test/integration/provider-scenarios/ios-fold.test.ts. fold: 255_000, @@ -260,7 +262,7 @@ test('interaction --settle budgets add post-action settle time on top of the nor positionals: ['300', '500', '120000'], flags: {}, }), - 210_000, + 310_000, ); // Bare --settle adds its default budget after the longpress-specific base, // so a maximum hold still leaves room for post-action observation. @@ -269,7 +271,7 @@ test('interaction --settle budgets add post-action settle time on top of the nor ...base, flags: { settle: true }, }), - 250_000, + 350_000, ); // Bare timeoutMs without --settle remains wire-compatible with older touch // command clients: it is ignored instead of opting into settle semantics. diff --git a/src/commands/interaction/runtime/gestures.ts b/src/commands/interaction/runtime/gestures.ts index c5d51bfc38..e8d2ed4428 100644 --- a/src/commands/interaction/runtime/gestures.ts +++ b/src/commands/interaction/runtime/gestures.ts @@ -1,4 +1,7 @@ -import type { DragGestureInput } from '@agent-device/contracts/gesture-plan-types'; +import { + LONG_PRESS_DURATION_MAX_MS, + type DragGestureInput, +} from '@agent-device/contracts/gesture-plan-types'; import type { HoverCommandResult, LongPressCommandResult, @@ -149,7 +152,7 @@ export const longPressCommand: RuntimeCommand< const durationMs = options.durationMs === undefined ? undefined - : requireIntInRange(options.durationMs, 'durationMs', 0, 120_000); + : requireIntInRange(options.durationMs, 'durationMs', 0, LONG_PRESS_DURATION_MAX_MS); const point = requireResolvedPoint(resolved); const backendResult = await runtime.backend.longPress(toBackendContext(runtime, options), point, { durationMs, diff --git a/test/integration/provider-scenarios/android-longpress-ledger.test.ts b/test/integration/provider-scenarios/android-longpress-ledger.test.ts new file mode 100644 index 0000000000..d4d82fc335 --- /dev/null +++ b/test/integration/provider-scenarios/android-longpress-ledger.test.ts @@ -0,0 +1,201 @@ +import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; +import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; +import { LONG_PRESS_DURATION_MAX_MS } from '@agent-device/contracts/gesture-plan-types'; +import type { + AndroidAdbExecutorOptions, + AndroidAdbExecutorResult, + AndroidAdbProvider, +} from '@agent-device/platform-android/mechanics'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test, vi } from 'vitest'; +import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../../src/__tests__/test-utils/android-snapshot-helper.ts'; +import { respondToAndroidSettingsAdbCommand } from './android-world.ts'; +import { assertRpcOk } from './assertions.ts'; +import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; +import { createProviderScenarioHarness } from './harness.ts'; + +// The longpress envelope must outlast the Android cold-helper route's worst case plus the margin +// that lets the daemon's own typed result arrive first; otherwise the client gives up on a request +// the device is still finishing. This test proves that bound against the real route: it imports no platform-android step figure, so it stays true +// however the route's steps change. The only imported figure is the hold cap the validator reads. +type LongPressLedgerCall = Readonly<{ + tool: 'exec' | 'install' | 'pull'; + args: readonly string[]; + timeoutMs: number; +}>; + +const HELPER = ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT.manifest; +const HELPER_REMOTE_APK = `/data/app/~~ledger/${HELPER.packageName}/base.apk`; +const ok: AndroidAdbExecutorResult = { stdout: '', stderr: '', exitCode: 0 }; + +function gestureHelperOutput(): string { + return [ + `INSTRUMENTATION_RESULT: agentDeviceProtocol=${HELPER.statusProtocol}`, + 'INSTRUMENTATION_RESULT: helperApiVersion=1', + 'INSTRUMENTATION_RESULT: ok=true', + 'INSTRUMENTATION_RESULT: kind=swipe', + 'INSTRUMENTATION_CODE: 0', + ].join('\n'); +} + +/** + * The slowest cold-helper branch the route allows: the installed helper reports the bundled + * versionCode, so identity falls through to the APK checksum (`pm path` + pull); the pulled APK + * mismatches, so the helper is replaced; the first replace is rejected as + * INSTALL_FAILED_UPDATE_INCOMPATIBLE, so the route uninstalls and installs again; then the hold runs + * one-shot because no live helper session exists. + */ +function createLongPressLedgerAdbProvider(params: { + isTracking: () => boolean; + onCall: (call: LongPressLedgerCall) => void; +}): { provider: AndroidAdbProvider; ledger: LongPressLedgerCall[] } { + const ledger: LongPressLedgerCall[] = []; + let installAttempts = 0; + // A call without a bounded timeoutMs is not a worst case the envelope assertion can see, so it + // fails the test rather than costing 0 virtual ms. + const record = ( + tool: LongPressLedgerCall['tool'], + args: readonly string[], + options?: AndroidAdbExecutorOptions, + ): void => { + if (!params.isTracking()) return; + assert.ok( + Number.isFinite(options?.timeoutMs) && options!.timeoutMs! > 0, + `longpress ledger call has no bounded timeoutMs: ${tool} ${args.join(' ')}`, + ); + const call: LongPressLedgerCall = { tool, args: [...args], timeoutMs: options!.timeoutMs! }; + ledger.push(call); + params.onCall(call); + }; + const provider: AndroidAdbProvider = { + snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + exec: async (args, options) => { + record('exec', args, options); + if (args.includes('--show-versioncode') && args.includes(HELPER.packageName)) { + return { + ...ok, + stdout: `package:${HELPER.packageName} versionCode:${HELPER.versionCode}\n`, + }; + } + if (args[0] === 'shell' && args[1] === 'pm' && args[2] === 'path') { + return { ...ok, stdout: `package:${HELPER_REMOTE_APK}\n` }; + } + if (args[0] === 'uninstall' && args[1] === HELPER.packageName) { + return { ...ok, stdout: 'Success\n' }; + } + if (args.includes('instrument') && args.includes('gesture')) { + return { ...ok, stdout: gestureHelperOutput() }; + } + if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'start') { + return { ...ok, stdout: 'Starting: Intent' }; + } + return respondToAndroidSettingsAdbCommand([...args], '', '', {}); + }, + pull: async (remotePath, localPath, options) => { + record('pull', [remotePath, localPath], options); + fs.writeFileSync(localPath, 'a previously installed helper build'); + return ok; + }, + install: async (apkPath, options) => { + record('install', [apkPath], options); + installAttempts += 1; + if (installAttempts === 1) { + return { + stdout: '', + stderr: 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: signatures do not match]', + exitCode: 1, + }; + } + return { ...ok, stdout: 'Success\n' }; + }, + }; + return { provider, ledger }; +} + +function gestureDurationMs(call: LongPressLedgerCall): number | undefined { + const payloadIndex = call.args.indexOf('payloadBase64'); + if (payloadIndex < 0) return undefined; + const payload = JSON.parse( + Buffer.from(call.args[payloadIndex + 1]!, 'base64').toString('utf8'), + ) as { durationMs?: unknown }; + return typeof payload.durationMs === 'number' ? payload.durationMs : undefined; +} + +function assertLongPressLedgerPhaseCoverage(ledger: readonly LongPressLedgerCall[]): void { + const printed = `\nLedger:\n${JSON.stringify(ledger, null, 2)}`; + const isVersionRead = (call: LongPressLedgerCall) => + call.tool === 'exec' && call.args.includes('--show-versioncode'); + const isApkPathRead = (call: LongPressLedgerCall) => + call.tool === 'exec' && call.args.includes('pm') && call.args.includes('path'); + const isGesture = (call: LongPressLedgerCall) => + call.tool === 'exec' && call.args.includes('instrument') && call.args.includes('gesture'); + + assert.ok(ledger.some(isVersionRead), `ledger is missing the helper version read${printed}`); + assert.ok(ledger.some(isApkPathRead), `ledger is missing the helper APK path read${printed}`); + assert.ok( + ledger.some((call) => call.tool === 'pull'), + `ledger is missing the helper APK pull${printed}`, + ); + assert.equal( + ledger.filter((call) => call.tool === 'install').length, + 2, + `ledger must record the rejected helper install and its retry${printed}`, + ); + assert.ok( + ledger.some((call) => call.args[0] === 'uninstall'), + `ledger is missing the incompatible-helper uninstall${printed}`, + ); + const gestures = ledger.filter(isGesture); + assert.equal(gestures.length, 1, `ledger must record exactly one gesture dispatch${printed}`); + assert.equal( + gestureDurationMs(gestures[0]!), + LONG_PRESS_DURATION_MAX_MS, + `the gesture dispatch must carry the maximum hold${printed}`, + ); +} + +test('longpress cold-helper worst-case ledger covers the client envelope with the daemon-result margin', async () => { + // Matches REQUEST_TIMEOUT_BUDGET_MARGIN_MS, packages/command-registry/src/timeout-policy.ts. + // Not imported: it is not exported (fallow would flag an export used only by a test). + const REQUIRED_DAEMON_RESULT_MARGIN_MS = 30_000; + + let virtualElapsedMs = 0; + let tracking = false; + const { provider, ledger } = createLongPressLedgerAdbProvider({ + isTracking: () => tracking, + onCall: (call) => { + virtualElapsedMs += Math.max(0, call.timeoutMs - 1); + }, + }); + const dateSpy = vi.spyOn(Date, 'now'); + const daemon = await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + androidAdbProvider: () => provider, + }); + const selection = { platform: 'android', serial: PROVIDER_SCENARIO_ANDROID.id } as const; + const positionals = ['195', '320', String(LONG_PRESS_DURATION_MAX_MS)]; + try { + await daemon.client().apps.open({ app: 'com.android.settings', ...selection, testIme: false }); + tracking = true; + const originNowMs = Date.now(); + dateSpy.mockImplementation(() => originNowMs + virtualElapsedMs); + assertRpcOk(await daemon.callCommand('longpress', positionals, selection)); + } finally { + dateSpy.mockRestore(); + await daemon.close(); + } + + assertLongPressLedgerPhaseCoverage(ledger); + + const envelopeMs = resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('longpress'), { + positionals, + flags: {}, + }); + assert.ok(envelopeMs !== undefined, 'longpress must declare a bounded envelope'); + assert.ok( + virtualElapsedMs + REQUIRED_DAEMON_RESULT_MARGIN_MS <= envelopeMs, + `longpress ledger worst case (${virtualElapsedMs}ms) + ${REQUIRED_DAEMON_RESULT_MARGIN_MS}ms ` + + `margin exceeds the ${envelopeMs}ms envelope.\nLedger:\n${JSON.stringify(ledger, null, 2)}`, + ); +}, 20_000); From 8a582e78c3fa081ef956244a143a1b0df01bb1bc Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Fri, 23 Oct 2026 23:30:07 -0400 Subject: [PATCH 3/7] fix(android): share one dump budget across an observation's variants A per-read bound still let one observation walk every dumpsys variant that answered without a focus line, each spending up to its own 10 s. All reads of one observation now draw on a single 10 s budget, so an observation costs at most one bound however many variants it reads; a spent budget or a timed-out read leaves the question unanswered. Co-Authored-By: Claude Opus 5.5 --- .../platform-android/src/observation.test.ts | 122 ++++++++++++------ packages/platform-android/src/observation.ts | 49 +++---- 2 files changed, 110 insertions(+), 61 deletions(-) diff --git a/packages/platform-android/src/observation.test.ts b/packages/platform-android/src/observation.test.ts index ea6441ae35..c20e807ff6 100644 --- a/packages/platform-android/src/observation.test.ts +++ b/packages/platform-android/src/observation.test.ts @@ -100,55 +100,103 @@ test('a transient empty dump never demotes a variant that previously answered', expect(calls[thirdReadStart]).toBe('shell dumpsys window windows'); }); -function timedOutAdb(args: readonly string[]): AppError { - return new AppError('COMMAND_FAILED', 'adb timed out', { cmd: 'adb', args, timeoutMs: 1 }); +function timedOutAdb(args: readonly string[], timeoutMs: number): AppError { + return new AppError('COMMAND_FAILED', 'adb timed out', { cmd: 'adb', args, timeoutMs }); } -test('bounds every dump read the foreground question makes', async () => { - const host = hostFor(new Map()); - const observer = createAndroidObservationAdapter(host); +type Observer = ReturnType; - await observer.readAppState({ ...device, id: 'emulator-bounded' }); +const QUESTIONS = [ + { + question: 'foreground', + ask: (observer: Observer, target: DeviceInfo) => observer.readAppState(target), + unanswered: {}, + }, + { + question: 'blocking dialog', + ask: (observer: Observer, target: DeviceInfo) => observer.readBlockingDialog(target), + unanswered: { status: 'unknown' }, + }, +] as const; + +/** + * A host whose every dump answers without a focus line after `spendMs(timeoutMs)` of a virtual + * clock, optionally timing out instead, so an observation walks every variant it is allowed to. + */ +function slowHost(params: { spendMs: (timeoutMs: number) => number; timeOut?: boolean }): { + host: AndroidObservationHost; + timeouts: number[]; + restore: () => void; +} { + const originMs = Date.now(); + let elapsedMs = 0; + const now = vi.spyOn(Date, 'now').mockImplementation(() => originMs + elapsedMs); + const timeouts: number[] = []; + const host: AndroidObservationHost = { + ...hostFor(new Map()), + runAdb: vi.fn(async (_device, args, options) => { + const timeoutMs = options?.timeoutMs ?? Number.POSITIVE_INFINITY; + timeouts.push(timeoutMs); + elapsedMs += params.spendMs(timeoutMs); + if (params.timeOut) throw timedOutAdb(args, timeoutMs); + return { exitCode: 0, stdout: '', stderr: '' }; + }), + }; + return { host, timeouts, restore: () => now.mockRestore() }; +} - const calls = vi.mocked(host.runAdb).mock.calls; - expect(calls.map(([, args]) => args.join(' '))).toEqual([ +test('bounds every dump read the foreground question makes', async () => { + const { host, timeouts, restore } = slowHost({ spendMs: () => 0 }); + try { + await createAndroidObservationAdapter(host).readAppState({ ...device, id: 'emulator-bounded' }); + } finally { + restore(); + } + + expect(vi.mocked(host.runAdb).mock.calls.map(([, args]) => args.join(' '))).toEqual([ 'shell dumpsys window windows', 'shell dumpsys window', 'shell dumpsys activity activities', 'shell dumpsys activity', ]); - for (const [, , options] of calls) { - expect(options).toEqual({ allowFailure: true, timeoutMs: expect.any(Number) }); - expect(options?.timeoutMs).toBeGreaterThan(0); + for (const timeoutMs of timeouts) expect(Number.isFinite(timeoutMs) && timeoutMs > 0).toBe(true); +}); + +test('later variants receive only what the earlier reads left of one observation budget', async () => { + const { host, timeouts, restore } = slowHost({ + spendMs: (timeoutMs) => Math.floor(timeoutMs / 2), + }); + try { + await createAndroidObservationAdapter(host).readAppState({ ...device, id: 'emulator-slow' }); + } finally { + restore(); + } + + const budgetMs = timeouts[0]!; + expect(timeouts.length).toBeGreaterThan(1); + for (let index = 1; index < timeouts.length; index += 1) { + expect(timeouts[index]).toBe(timeouts[index - 1]! - Math.floor(timeouts[index - 1]! / 2)); } + const spentMs = timeouts.reduce((sum, timeoutMs) => sum + Math.floor(timeoutMs / 2), 0); + expect(spentMs).toBeLessThanOrEqual(budgetMs); }); -test.each([ - { - question: 'foreground', - ask: (observer: ReturnType, target: DeviceInfo) => - observer.readAppState(target), - unanswered: {}, - }, - { - question: 'blocking dialog', - ask: (observer: ReturnType, target: DeviceInfo) => - observer.readBlockingDialog(target), - unanswered: { status: 'unknown' }, - }, -])( - 'a timed-out dump leaves the $question question unanswered and reads no further variant', - async ({ question, ask, unanswered }) => { - const host: AndroidObservationHost = { - ...hostFor(new Map()), - runAdb: vi.fn(async (_device, args) => { - throw timedOutAdb(args); - }), - }; - - await expect( - ask(createAndroidObservationAdapter(host), { ...device, id: `emulator-hung-${question}` }), - ).resolves.toEqual(unanswered); +test.each( + QUESTIONS.flatMap((entry) => [ + { ...entry, ending: 'times out', timeOut: true }, + { ...entry, ending: 'answers at its bound', timeOut: false }, + ]), +)( + 'a $question read that $ending spends the observation budget and leaves it unanswered', + async ({ question, ask, unanswered, timeOut }) => { + const { host, restore } = slowHost({ spendMs: (timeoutMs) => timeoutMs, timeOut }); + try { + await expect( + ask(createAndroidObservationAdapter(host), { ...device, id: `emulator-${question}` }), + ).resolves.toEqual(unanswered); + } finally { + restore(); + } expect(host.runAdb).toHaveBeenCalledTimes(1); }, ); diff --git a/packages/platform-android/src/observation.ts b/packages/platform-android/src/observation.ts index 442fd9311d..6538312dd8 100644 --- a/packages/platform-android/src/observation.ts +++ b/packages/platform-android/src/observation.ts @@ -33,7 +33,7 @@ const PERMISSION_PACKAGES = new Set([ 'com.google.android.packageinstaller', ]); -const DUMP_READ_TIMEOUT_MS = 10_000; +const DUMP_OBSERVATION_BUDGET_MS = 10_000; type Question = 'foreground' | 'blockingDialog'; type DumpReader = (words: readonly string[]) => Promise; @@ -83,34 +83,35 @@ export function createAndroidObservationAdapter( } /** - * A dump that times out is an unanswered read, like one that printed nothing: the observation - * reports `unknown` and never refuses the command. After one timeout the device is not answering, - * so the reader skips its remaining variants and an observation costs at most one bound. + * Every variant one observation reads shares a single {@link DUMP_OBSERVATION_BUDGET_MS} budget, + * so an observation costs at most one bound however many variants it walks. A read that times out + * or finds the budget spent is an unanswered read, like one that printed nothing: the observation + * reports `unknown` and never refuses the command. */ function createDumpReader(host: AndroidObservationHost, device: DeviceInfo): DumpReader { const dumps = new Map>(); - let timedOut = false; + const deadlineMs = Date.now() + DUMP_OBSERVATION_BUDGET_MS; return (words) => { - if (timedOut) return Promise.resolve(''); const key = words.join(' '); - const pending = - dumps.get(key) ?? - host - .runAdb(device, deviceShellArgv('adb', 'shell', words), { - allowFailure: true, - timeoutMs: DUMP_READ_TIMEOUT_MS, - }) - .then( - (result) => { - recordSections(device, key, result.stdout); - return result.stdout; - }, - (error: unknown) => { - if (!isCommandTimeoutError(error)) throw error; - timedOut = true; - return ''; - }, - ); + const cached = dumps.get(key); + if (cached) return cached; + const remainingMs = deadlineMs - Date.now(); + if (remainingMs <= 0) return Promise.resolve(''); + const pending = host + .runAdb(device, deviceShellArgv('adb', 'shell', words), { + allowFailure: true, + timeoutMs: remainingMs, + }) + .then( + (result) => { + recordSections(device, key, result.stdout); + return result.stdout; + }, + (error: unknown) => { + if (!isCommandTimeoutError(error)) throw error; + return ''; + }, + ); dumps.set(key, pending); return pending; }; From e10db99755b7564299e76a6b9c5794d52b1c4857 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Fri, 23 Oct 2026 23:34:02 -0400 Subject: [PATCH 4/7] fix(timeouts): charge every observation variant in the longpress ledger The ledger's fake answered the first dumpsys variant of every observation, so it charged one read each and never saw the foreground check walk into a second observation. Observation dumps now answer without a focus line, which sends each observation through every variant its shared budget allows. The measured worst case rises to 289,989 ms, so the envelope rises from 310 s to 320 s. Also: name the snapshot-helper hand-off the ledger cannot charge and where it is bounded, say the Android-driven envelope applies to every platform, read timeoutMs once in record(), and rewrap the header. Co-Authored-By: Claude Opus 5.5 --- packages/command-registry/src/registry.ts | 6 ++-- .../command-descriptor-timeout-policy.test.ts | 6 ++-- .../android-longpress-ledger.test.ts | 29 ++++++++++++++++--- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 025313e42a..ec0a548d20 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -1298,10 +1298,10 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', timeoutPolicy: { ...SETTLE_FLAG_PRESERVE_DAEMON_TIMEOUT_POLICY, - // Covers the Android cold-helper worst case at the maximum hold plus the - // daemon-result margin. Proven by the ledger test: + // Android cold-helper worst case at the maximum hold plus the daemon-result margin, applied + // to every platform. Proven by the ledger test: // test/integration/provider-scenarios/android-longpress-ledger.test.ts. - envelopeMs: 310_000, + envelopeMs: 320_000, }, postActionObservation: postActionObservation('longpress'), batchable: true, diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index 3fac0b8531..d47866543d 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -151,7 +151,7 @@ test('request envelopes deviating from the default are bounded, reviewed sets', install_source: 180_000, // longpress: proven by the worst-case ledger test in // test/integration/provider-scenarios/android-longpress-ledger.test.ts. - longpress: 310_000, + longpress: 320_000, // fold: proven by the worst-case ledger test in // test/integration/provider-scenarios/ios-fold.test.ts. fold: 255_000, @@ -262,7 +262,7 @@ test('interaction --settle budgets add post-action settle time on top of the nor positionals: ['300', '500', '120000'], flags: {}, }), - 310_000, + 320_000, ); // Bare --settle adds its default budget after the longpress-specific base, // so a maximum hold still leaves room for post-action observation. @@ -271,7 +271,7 @@ test('interaction --settle budgets add post-action settle time on top of the nor ...base, flags: { settle: true }, }), - 350_000, + 360_000, ); // Bare timeoutMs without --settle remains wire-compatible with older touch // command clients: it is ignored instead of opting into settle semantics. diff --git a/test/integration/provider-scenarios/android-longpress-ledger.test.ts b/test/integration/provider-scenarios/android-longpress-ledger.test.ts index d4d82fc335..cf6876a5a2 100644 --- a/test/integration/provider-scenarios/android-longpress-ledger.test.ts +++ b/test/integration/provider-scenarios/android-longpress-ledger.test.ts @@ -17,8 +17,15 @@ import { createProviderScenarioHarness } from './harness.ts'; // The longpress envelope must outlast the Android cold-helper route's worst case plus the margin // that lets the daemon's own typed result arrive first; otherwise the client gives up on a request -// the device is still finishing. This test proves that bound against the real route: it imports no platform-android step figure, so it stays true -// however the route's steps change. The only imported figure is the hold cap the validator reads. +// the device is still finishing. This test proves that bound against the real route: it imports no +// platform-android step figure, so it stays true however the route's steps change. The only +// imported figure is the hold cap the validator reads. +// +// One step stays outside this ledger: replacing the helper stops a running snapshot helper session +// first, and that hand-off waits on host-side timers in +// packages/platform-android/src/snapshot-helper-session-lifecycle.ts (stop request, graceful exit, +// process exit) rather than on a provider call this fake can charge. Its device-side stop does go +// through the provider and is charged when it runs. type LongPressLedgerCall = Readonly<{ tool: 'exec' | 'install' | 'pull'; args: readonly string[]; @@ -60,11 +67,12 @@ function createLongPressLedgerAdbProvider(params: { options?: AndroidAdbExecutorOptions, ): void => { if (!params.isTracking()) return; + const timeoutMs = options?.timeoutMs; assert.ok( - Number.isFinite(options?.timeoutMs) && options!.timeoutMs! > 0, + timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0, `longpress ledger call has no bounded timeoutMs: ${tool} ${args.join(' ')}`, ); - const call: LongPressLedgerCall = { tool, args: [...args], timeoutMs: options!.timeoutMs! }; + const call: LongPressLedgerCall = { tool, args: [...args], timeoutMs }; ledger.push(call); params.onCall(call); }; @@ -72,6 +80,9 @@ function createLongPressLedgerAdbProvider(params: { snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, exec: async (args, options) => { record('exec', args, options); + // A dump without a focus line sends the dialog and foreground observations through every + // variant they may read, so each one spends its whole budget. + if (params.isTracking() && isObservationDump(args)) return ok; if (args.includes('--show-versioncode') && args.includes(HELPER.packageName)) { return { ...ok, @@ -113,6 +124,12 @@ function createLongPressLedgerAdbProvider(params: { return { provider, ledger }; } +function isObservationDump(args: readonly string[]): boolean { + return ( + args[0] === 'shell' && args[1] === 'dumpsys' && (args[2] === 'window' || args[2] === 'activity') + ); +} + function gestureDurationMs(call: LongPressLedgerCall): number | undefined { const payloadIndex = call.args.indexOf('payloadBase64'); if (payloadIndex < 0) return undefined; @@ -146,6 +163,10 @@ function assertLongPressLedgerPhaseCoverage(ledger: readonly LongPressLedgerCall ledger.some((call) => call.args[0] === 'uninstall'), `ledger is missing the incompatible-helper uninstall${printed}`, ); + assert.ok( + ledger.some((call) => call.tool === 'exec' && isObservationDump(call.args)), + `ledger is missing the dialog and foreground observation dumps${printed}`, + ); const gestures = ledger.filter(isGesture); assert.equal(gestures.length, 1, `ledger must record exactly one gesture dispatch${printed}`); assert.equal( From 31676642a14558a57c71ee5a393c90798cf79609 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 24 Oct 2026 00:31:04 -0400 Subject: [PATCH 5/7] fix(android): start an observation's dump budget at its first dump read The budget started when the reader was created, so the helper focus call that readBlockingDialog (and readAppFocus, for a shared reader) makes first spent up to 2 s of the 10 s dump budget. The deadline now starts at the first dump read. Co-Authored-By: Claude Opus 5.5 --- .../platform-android/src/observation.test.ts | 45 ++++++++++++++++++- packages/platform-android/src/observation.ts | 9 ++-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/platform-android/src/observation.test.ts b/packages/platform-android/src/observation.test.ts index c20e807ff6..c9255531fd 100644 --- a/packages/platform-android/src/observation.test.ts +++ b/packages/platform-android/src/observation.test.ts @@ -2,8 +2,14 @@ import { expect, test, vi } from 'vitest'; import type { AndroidObservationHost } from '@agent-device/contracts/android-observation'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { runAndroidAutomationHelper } from './automation-helper.ts'; import { createAndroidObservationAdapter } from './observation.ts'; +vi.mock('./automation-helper.ts', async (importOriginal) => ({ + ...(await importOriginal()), + runAndroidAutomationHelper: vi.fn(async () => undefined), +})); + const device = { platform: 'android', target: 'mobile', @@ -126,6 +132,7 @@ const QUESTIONS = [ function slowHost(params: { spendMs: (timeoutMs: number) => number; timeOut?: boolean }): { host: AndroidObservationHost; timeouts: number[]; + advance: (ms: number) => void; restore: () => void; } { const originMs = Date.now(); @@ -142,7 +149,14 @@ function slowHost(params: { spendMs: (timeoutMs: number) => number; timeOut?: bo return { exitCode: 0, stdout: '', stderr: '' }; }), }; - return { host, timeouts, restore: () => now.mockRestore() }; + return { + host, + timeouts, + advance: (ms) => { + elapsedMs += ms; + }, + restore: () => now.mockRestore(), + }; } test('bounds every dump read the foreground question makes', async () => { @@ -214,3 +228,32 @@ test('a dump that fails without timing out still fails the observation', async ( createAndroidObservationAdapter(host).readAppState({ ...device, id: 'emulator-offline' }), ).rejects.toBe(failure); }); + +test('the helper focus call before the first dump does not spend the dump budget', async () => { + const unhurried = slowHost({ spendMs: () => 0 }); + try { + await createAndroidObservationAdapter(unhurried.host).readBlockingDialog({ + ...device, + id: 'emulator-focus-fast', + }); + } finally { + unhurried.restore(); + } + + const slowFocus = slowHost({ spendMs: () => 0 }); + const focusCostMs = Math.floor(unhurried.timeouts[0]! / 2); + vi.mocked(runAndroidAutomationHelper).mockImplementationOnce(async () => { + slowFocus.advance(focusCostMs); + return undefined; + }); + try { + await createAndroidObservationAdapter(slowFocus.host).readBlockingDialog({ + ...device, + id: 'emulator-focus-slow', + }); + } finally { + slowFocus.restore(); + } + + expect(slowFocus.timeouts[0]).toBe(unhurried.timeouts[0]); +}); diff --git a/packages/platform-android/src/observation.ts b/packages/platform-android/src/observation.ts index 6538312dd8..a3bf7c0cbd 100644 --- a/packages/platform-android/src/observation.ts +++ b/packages/platform-android/src/observation.ts @@ -84,17 +84,18 @@ export function createAndroidObservationAdapter( /** * Every variant one observation reads shares a single {@link DUMP_OBSERVATION_BUDGET_MS} budget, - * so an observation costs at most one bound however many variants it walks. A read that times out - * or finds the budget spent is an unanswered read, like one that printed nothing: the observation - * reports `unknown` and never refuses the command. + * counted from its first dump read, so an observation costs at most one bound however many + * variants it walks. A read that times out or finds the budget spent is an unanswered read, like + * one that printed nothing: the observation reports `unknown` and never refuses the command. */ function createDumpReader(host: AndroidObservationHost, device: DeviceInfo): DumpReader { const dumps = new Map>(); - const deadlineMs = Date.now() + DUMP_OBSERVATION_BUDGET_MS; + let deadlineMs: number | undefined; return (words) => { const key = words.join(' '); const cached = dumps.get(key); if (cached) return cached; + deadlineMs ??= Date.now() + DUMP_OBSERVATION_BUDGET_MS; const remainingMs = deadlineMs - Date.now(); if (remainingMs <= 0) return Promise.resolve(''); const pending = host From f02edea8ecbba480c8204c5001c48dd2afceeed6 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 24 Oct 2026 00:35:24 -0400 Subject: [PATCH 6/7] refactor(android): track the dump budget with host-kit's Deadline Matches the other platform-android modules that bound a sequence of steps, instead of computing the deadline and remainder by hand. Co-Authored-By: Claude Opus 5.5 --- packages/platform-android/src/observation.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/platform-android/src/observation.ts b/packages/platform-android/src/observation.ts index a3bf7c0cbd..5c190f61a0 100644 --- a/packages/platform-android/src/observation.ts +++ b/packages/platform-android/src/observation.ts @@ -6,6 +6,7 @@ import type { import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { isCommandTimeoutError } from '@agent-device/host-kit/command'; +import { Deadline } from '@agent-device/host-kit/retry'; import { deviceShellArgv } from '@agent-device/kernel/device-shell'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -90,18 +91,17 @@ export function createAndroidObservationAdapter( */ function createDumpReader(host: AndroidObservationHost, device: DeviceInfo): DumpReader { const dumps = new Map>(); - let deadlineMs: number | undefined; + let deadline: Deadline | undefined; return (words) => { const key = words.join(' '); const cached = dumps.get(key); if (cached) return cached; - deadlineMs ??= Date.now() + DUMP_OBSERVATION_BUDGET_MS; - const remainingMs = deadlineMs - Date.now(); - if (remainingMs <= 0) return Promise.resolve(''); + deadline ??= Deadline.fromTimeoutMs(DUMP_OBSERVATION_BUDGET_MS); + if (deadline.isExpired()) return Promise.resolve(''); const pending = host .runAdb(device, deviceShellArgv('adb', 'shell', words), { allowFailure: true, - timeoutMs: remainingMs, + timeoutMs: deadline.remainingMs(), }) .then( (result) => { From cc4dd4a2f0912656851ef00381e7a26e0d956e24 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 24 Oct 2026 01:35:48 -0400 Subject: [PATCH 7/7] fix(android): read the dump deadline once per read isExpired() and remainingMs() read the clock separately, so a millisecond rollover between them passed timeoutMs 0, which the exec layer treats as no timeout and runs the dump unbounded. The reader now reads the remaining budget once and uses it for both the check and the timeout. Co-Authored-By: Claude Opus 5.5 --- .../platform-android/src/observation.test.ts | 31 +++++++++++++++++++ packages/platform-android/src/observation.ts | 5 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/platform-android/src/observation.test.ts b/packages/platform-android/src/observation.test.ts index c9255531fd..5fc3ebf497 100644 --- a/packages/platform-android/src/observation.test.ts +++ b/packages/platform-android/src/observation.test.ts @@ -257,3 +257,34 @@ test('the helper focus call before the first dump does not spend the dump budget expect(slowFocus.timeouts[0]).toBe(unhurried.timeouts[0]); }); + +test('a read that starts with the budget nearly spent never passes a non-positive timeout', async () => { + // Each clock read advances 1 ms, so two reads of one deadline disagree by one millisecond. + const originMs = Date.now(); + let elapsedMs = 0; + const now = vi.spyOn(Date, 'now').mockImplementation(() => { + elapsedMs += 1; + return originMs + elapsedMs; + }); + const timeouts: number[] = []; + const host: AndroidObservationHost = { + ...hostFor(new Map()), + runAdb: vi.fn(async (_device, _args, options) => { + const timeoutMs = options?.timeoutMs ?? Number.POSITIVE_INFINITY; + timeouts.push(timeoutMs); + elapsedMs += timeoutMs - 2; + return { exitCode: 0, stdout: '', stderr: '' }; + }), + }; + try { + await createAndroidObservationAdapter(host).readAppState({ + ...device, + id: 'emulator-rollover', + }); + } finally { + now.mockRestore(); + } + + expect(timeouts.length).toBeGreaterThan(1); + for (const timeoutMs of timeouts) expect(timeoutMs).toBeGreaterThan(0); +}); diff --git a/packages/platform-android/src/observation.ts b/packages/platform-android/src/observation.ts index 5c190f61a0..47beb8a2ac 100644 --- a/packages/platform-android/src/observation.ts +++ b/packages/platform-android/src/observation.ts @@ -97,11 +97,12 @@ function createDumpReader(host: AndroidObservationHost, device: DeviceInfo): Dum const cached = dumps.get(key); if (cached) return cached; deadline ??= Deadline.fromTimeoutMs(DUMP_OBSERVATION_BUDGET_MS); - if (deadline.isExpired()) return Promise.resolve(''); + const remainingMs = deadline.remainingMs(); + if (remainingMs <= 0) return Promise.resolve(''); const pending = host .runAdb(device, deviceShellArgv('adb', 'shell', words), { allowFailure: true, - timeoutMs: deadline.remainingMs(), + timeoutMs: remainingMs, }) .then( (result) => {