From 1d2418effee742debc6320e5b73613c70584098f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 13:45:33 +0200 Subject: [PATCH 01/12] fix: bound batch and replay requests with daemon deadlines --- packages/command-registry/src/registry.ts | 10 +- .../command-registry/src/timeout-policy.ts | 13 ++ packages/command-registry/src/types.ts | 5 +- .../src/core/daemon-command-registry.ts | 6 + .../__tests__/request-execution-locks.test.ts | 46 +++- .../src/server/request-execution-locks.ts | 30 ++- .../src/server/request-execution-scope.ts | 1 + packages/daemon/src/server/request-router.ts | 31 ++- .../src/internal/request-cancel.test.ts | 179 +++++++++++++++- .../host-kit/src/internal/request-cancel.ts | 59 +++++- packages/host-kit/src/request.ts | 1 + packages/kernel/src/keyed-lock.test.ts | 61 ++++++ packages/kernel/src/keyed-lock.ts | 16 +- .../snapshot-helper-retirement.test.ts | 6 +- .../__tests__/touch-helper-session.test.ts | 61 +++++- .../src/automation-helper-artifact.ts | 7 +- .../platform-android/src/input-actions.ts | 33 +-- .../src/snapshot-helper-capture.ts | 6 +- .../src/snapshot-helper-retirement.ts | 4 +- .../src/snapshot-helper-session.ts | 8 +- .../platform-android/src/touch-executor.ts | 4 +- packages/platform-android/src/touch-helper.ts | 28 ++- .../command-descriptor-timeout-policy.test.ts | 4 +- src/commands/batch/index.ts | 1 + src/commands/replay/index.ts | 6 +- src/core/interactors/android.ts | 5 +- .../composite-deadline.test.ts | 147 +++++++++++++ .../provider-scenarios/ios-fold.test.ts | 200 +++++++++++++++++- website/docs/docs/batching.md | 7 + website/docs/docs/replay-e2e.md | 8 + 30 files changed, 923 insertions(+), 70 deletions(-) create mode 100644 test/integration/provider-scenarios/composite-deadline.test.ts diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index f8a8e4eda5..8cb1599a86 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -859,8 +859,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ skipSessionlessProviderDevice: isShardedTestRequest, saveScriptFlagOwner: true, }, - // Replay durations are script-dependent; --timeout bounds the envelope. - timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, budget: { source: 'flag' } }, + // The daemon owns the operation budget; the client leaves time for its typed result. + timeoutPolicy: { + ...DEFAULT_TIMEOUT_POLICY, + daemonDeadlineMs: 60_000, + budget: { source: 'flag', envelope: 'margin' }, + }, batchable: false, // Every native and Maestro action re-enters the daemon request pipeline under the delegated // command descriptor. Maestro viewport reads do the same through `runtime gesture-viewport`, @@ -1081,7 +1085,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: false, daemon: { route: 'session', refFrameEffect: 'delegated', appLease: 'allowed' }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, daemonDeadlineMs: 60_000 }, batchable: false, // Wave 6 residue: every step runs as its own daemon request under its own descriptor, which // is what `refFrameEffect: 'delegated'` already says. `batch` itself reaches no device. diff --git a/packages/command-registry/src/timeout-policy.ts b/packages/command-registry/src/timeout-policy.ts index 2c2d950d22..53f0f77080 100644 --- a/packages/command-registry/src/timeout-policy.ts +++ b/packages/command-registry/src/timeout-policy.ts @@ -77,6 +77,8 @@ export function resolveCommandRequestTimeoutMs( input: RequestTimeoutInput, ): number | undefined { if (policy.envelopeMs === 'unbounded') return undefined; + const daemonBudgetMs = resolveCommandDaemonTimeoutMs(policy, input); + if (daemonBudgetMs !== undefined) return daemonBudgetMs + REQUEST_TIMEOUT_BUDGET_MARGIN_MS; const boundedPolicy: BoundedTimeoutPolicy = { ...policy, envelopeMs: policy.envelopeMs }; return ( resolvePositionalBudgetTimeoutMs(boundedPolicy, input.positionals ?? []) ?? @@ -85,6 +87,17 @@ export function resolveCommandRequestTimeoutMs( ); } +/** Resolves an enforced daemon request deadline, when the command declares one. */ +export function resolveCommandDaemonTimeoutMs( + policy: CommandTimeoutPolicy, + input: RequestTimeoutInput, +): number | undefined { + if (policy.daemonDeadlineMs === undefined) return undefined; + return policy.budget.source === 'flag' && typeof input.flags?.timeoutMs === 'number' + ? input.flags.timeoutMs + : policy.daemonDeadlineMs; +} + function resolvePositionalBudgetTimeoutMs( policy: BoundedTimeoutPolicy, positionals: string[], diff --git a/packages/command-registry/src/types.ts b/packages/command-registry/src/types.ts index 934bd0bc38..8fd32af92e 100644 --- a/packages/command-registry/src/types.ts +++ b/packages/command-registry/src/types.ts @@ -31,8 +31,7 @@ export type DaemonCommandTraits = Omit; * - `'none'` — the command has no user-supplied budget; the request * envelope is exactly `envelopeMs`. * - `'flag'` — the `--timeout` flag (`flags.timeoutMs`). By default it - * REPLACES the envelope (replay semantics: --timeout - * bounds the request). With `envelope: 'widen'` it only + * REPLACES the envelope. With `envelope: 'widen'` it only * ever EXTENDS the envelope to envelopeMs + budget + * margin (interaction --settle semantics, #1101: the * flag bounds a post-action wait, so the request must @@ -73,6 +72,8 @@ export type CommandTimeoutBudget = */ export type CommandTimeoutPolicy = { budget: CommandTimeoutBudget; + /** Default daemon wall-clock budget. Its envelope is the effective budget plus the result margin. */ + daemonDeadlineMs?: number; envelopeMs: number | 'unbounded'; onTimeout: 'preserve-daemon' | 'reset-daemon'; }; diff --git a/packages/daemon/src/core/daemon-command-registry.ts b/packages/daemon/src/core/daemon-command-registry.ts index 906c2dee36..fbbe12988c 100644 --- a/packages/daemon/src/core/daemon-command-registry.ts +++ b/packages/daemon/src/core/daemon-command-registry.ts @@ -9,7 +9,9 @@ import { commandDescriptors, resolveCommandRecordingEffect, resolveCommandDeviceClaimPolicy, + resolveCommandTimeoutPolicy, } from '@agent-device/command-registry/registry'; +import { resolveCommandDaemonTimeoutMs } from '@agent-device/command-registry/timeout-policy'; import type { RefFrameEffect } from '@agent-device/contracts/replay'; import type { DaemonRequest } from './daemon-request.ts'; @@ -31,6 +33,10 @@ export const DAEMON_COMMAND_DESCRIPTORS: readonly DaemonCommandDescriptor[] = const DAEMON_COMMAND_REGISTRY = buildDaemonCommandRegistry(DAEMON_COMMAND_DESCRIPTORS); +export function resolveDaemonRequestTimeoutMs(req: DaemonRequest): number | undefined { + return resolveCommandDaemonTimeoutMs(resolveCommandTimeoutPolicy(req.command), req); +} + export function getDaemonCommandRoute(command: string): DaemonCommandRoute { return getDaemonCommandDescriptor(command)?.route ?? 'generic'; } diff --git a/packages/daemon/src/server/__tests__/request-execution-locks.test.ts b/packages/daemon/src/server/__tests__/request-execution-locks.test.ts index dd4b13c016..8f39e28f93 100644 --- a/packages/daemon/src/server/__tests__/request-execution-locks.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-locks.test.ts @@ -1,6 +1,50 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; import { createRequestExecutionLocks } from '../request-execution-locks.ts'; +test.each(['initial', 'retained'] as const)( + 'cancellation ends a queued %s lock without running device work', + async (kind) => { + const locks = new Map>(); + const controller = new AbortController(); + let release!: () => void; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const owner = createRequestExecutionLocks({ locks, initialKeys: ['device:SIM-001'] }).run( + async () => { + entered(); + await new Promise((resolve) => { + release = resolve; + }); + }, + ); + await started; + const queuedLocks = createRequestExecutionLocks({ + locks, + initialKeys: kind === 'initial' ? ['device:SIM-001'] : ['session:queued'], + scope: { signal: controller.signal }, + }); + const deviceWork = vi.fn(); + const queued = queuedLocks.run(async () => { + if (kind === 'retained') await queuedLocks.retainDevice('SIM-001'); + deviceWork(); + }); + await new Promise((resolve) => setImmediate(resolve)); + const reason = new Error('deadline'); + const refused = expect(queued).rejects.toBe(reason); + controller.abort(reason); + await refused; + release(); + await owner; + await createRequestExecutionLocks({ locks, initialKeys: ['device:SIM-001'] }).run( + async () => {}, + ); + expect(deviceWork).not.toHaveBeenCalled(); + expect(locks.size).toBe(0); + }, +); + test('a dynamically retained device lock lasts until the owning request completes', async () => { const locks = new Map>(); const replayLocks = createRequestExecutionLocks({ diff --git a/packages/daemon/src/server/request-execution-locks.ts b/packages/daemon/src/server/request-execution-locks.ts index 13adc70d3c..b6ca6f2da7 100644 --- a/packages/daemon/src/server/request-execution-locks.ts +++ b/packages/daemon/src/server/request-execution-locks.ts @@ -20,8 +20,9 @@ export type RequestExecutionLocks = { export function createRequestExecutionLocks(params: { locks: Map>; initialKeys: RequestExecutionLockKey[]; + scope?: Readonly<{ signal: AbortSignal }>; }): RequestExecutionLocks { - const { locks, initialKeys } = params; + const { locks, initialKeys, scope } = params; const initialKeySet = new Set(initialKeys); const retainedLocks = new Map(); let running = false; @@ -40,7 +41,7 @@ export function createRequestExecutionLocks(params: { } running = true; try { - return await withRequestExecutionLockKeys(locks, initialKeys, task); + return await withRequestExecutionLockKeys(locks, initialKeys, task, scope?.signal); } finally { try { await releaseRetainedLocks(); @@ -58,7 +59,7 @@ export function createRequestExecutionLocks(params: { let retained = retainedLocks.get(key); if (!retained) { - retained = retainExecutionLock(locks, key); + retained = retainExecutionLock(locks, key, scope?.signal); retainedLocks.set(key, retained); } await retained.acquired; @@ -70,31 +71,42 @@ export async function withRequestExecutionLockKeys( locks: Map>, keys: readonly RequestExecutionLockKey[], task: () => Promise, + signal?: AbortSignal, ): Promise { const [key, ...remainingKeys] = keys; if (!key) return await task(); return await withKeyedLock( locks, key, - async () => await withRequestExecutionLockKeys(locks, remainingKeys, task), + async () => await withRequestExecutionLockKeys(locks, remainingKeys, task, signal), + signal, ); } function retainExecutionLock( locks: Map>, key: RequestExecutionLockKey, + signal?: AbortSignal, ): RetainedExecutionLock { let markAcquired: () => void = () => {}; + let rejectAcquired: (error: unknown) => void = () => {}; let release: () => void = () => {}; - const acquired = new Promise((resolve) => { + const acquired = new Promise((resolve, reject) => { markAcquired = resolve; + rejectAcquired = reject; }); const released = new Promise((resolve) => { release = resolve; }); - const completion = withKeyedLock(locks, key, async () => { - markAcquired(); - await released; - }); + const completion = withKeyedLock( + locks, + key, + async () => { + markAcquired(); + await released; + }, + signal, + ); + void completion.catch((error) => rejectAcquired(error)); return { acquired, completion, release }; } diff --git a/packages/daemon/src/server/request-execution-scope.ts b/packages/daemon/src/server/request-execution-scope.ts index a78869c3b6..4ec55af297 100644 --- a/packages/daemon/src/server/request-execution-scope.ts +++ b/packages/daemon/src/server/request-execution-scope.ts @@ -260,6 +260,7 @@ export async function createRequestExecutionScope(params: { const requestExecutionLocks = createRequestExecutionLocks({ locks: executionLocks, initialKeys: lockPlan.keys, + scope: params.platformRequestScope, }); const dispatchLedger = requestDispatchLedger(scopedReq); const { claimAdmission, runtimeBindings } = createRequestDeviceAccess({ diff --git a/packages/daemon/src/server/request-router.ts b/packages/daemon/src/server/request-router.ts index f1da1f387c..72fec0c6ad 100644 --- a/packages/daemon/src/server/request-router.ts +++ b/packages/daemon/src/server/request-router.ts @@ -56,7 +56,11 @@ import { type RequestExecutionScope, } from './request-execution-scope.ts'; import { unsupportedSaveScriptFlagResponse } from './request-save-script-policy.ts'; -import { canRunReplayScopedAction } from '../core/daemon-command-registry.ts'; +import { + canRunReplayScopedAction, + resolveDaemonRequestTimeoutMs, +} from '../core/daemon-command-registry.ts'; +import { resolveRequestTrackingId, withRequestDeadline } from '@agent-device/host-kit/request'; import { isWebSession } from '../sessions/web-session-names.ts'; import { inferFillText, @@ -185,6 +189,12 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { : deps.deviceInventoryGateways; async function handleRequest(req: DaemonRequest): Promise { + if (resolveDaemonRequestTimeoutMs(req) !== undefined) { + req = { + ...req, + meta: { ...req.meta, requestId: resolveRequestTrackingId(req.meta?.requestId) }, + }; + } return await reportEndedLeases(req, handleRequestOnLedger); } @@ -200,10 +210,21 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { logPath, }, async () => { - const response = await withCommandHost( - { host, requestEnv: requestCommandEnv(req) }, - async () => await runRequestWithinScope(req), - ); + const run = async () => + await withCommandHost( + { host, requestEnv: requestCommandEnv(req) }, + async () => await runRequestWithinScope(req), + ); + let response: DaemonResponse; + try { + const timeoutMs = resolveDaemonRequestTimeoutMs(req); + response = + timeoutMs === undefined + ? await run() + : await withRequestDeadline(req.meta!.requestId!, timeoutMs, run); + } catch (error) { + response = finalizeThrownRequestError(error); + } if (!response.ok) { // ADR 0012 decision 6, R7 (C5a): a command that finds no session but // hits a live repair tombstone gets `REPAIR_SESSION_EXPIRED` with diff --git a/packages/host-kit/src/internal/request-cancel.test.ts b/packages/host-kit/src/internal/request-cancel.test.ts index 915a9d0087..91904ac968 100644 --- a/packages/host-kit/src/internal/request-cancel.test.ts +++ b/packages/host-kit/src/internal/request-cancel.test.ts @@ -1,4 +1,5 @@ -import { test } from 'vitest'; +import { test, vi } from 'vitest'; +import type { DaemonResponse } from '@agent-device/kernel/contracts'; import assert from 'node:assert/strict'; import { AppError, @@ -10,6 +11,9 @@ import { markRequestCanceled, registerRequestAbort, resolveRequestTrackingId, + getRequestSignal, + throwIfRequestCanceled, + withRequestDeadline, } from './request-cancel.ts'; test('resolveRequestTrackingId generates unique ids for fallback seeds', () => { @@ -20,6 +24,179 @@ test('resolveRequestTrackingId generates unique ids for fallback seeds', () => { assert.notEqual(first, second); }); +test('daemon deadline cancels in-flight work with its typed reason and retains registration until settlement', async () => { + vi.useFakeTimers(); + const registration = registerRequestAbort('deadline'); + try { + const signal = getRequestSignal('deadline')!; + let finish!: () => void; + const task = withRequestDeadline('deadline', 60_000, async () => { + await new Promise((resolve) => { + finish = resolve; + }); + throw new Error('transport aborted without a response'); + }); + const rejected = assert.rejects( + task, + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'daemon_request_timeout' && + error.details.timeoutMs === 60_000, + ); + await vi.advanceTimersByTimeAsync(60_000); + assert.equal(signal.aborted, true); + assert.equal(getRequestSignal('deadline'), signal); + finish(); + await rejected; + assert.equal(getRequestSignal('deadline'), signal); + } finally { + clearRequestAbortRegistration(registration); + vi.useRealTimers(); + } +}); + +test('a completed daemon deadline releases its timer and preserves later cancellation', async () => { + vi.useFakeTimers(); + const registration = registerRequestAbort('completed-deadline'); + try { + const response: DaemonResponse = { ok: true, data: { completed: true } }; + assert.equal( + await withRequestDeadline('completed-deadline', 100, async () => response), + response, + ); + await vi.advanceTimersByTimeAsync(100); + assert.equal(getRequestSignal('completed-deadline')?.aborted, false); + markRequestCanceled('completed-deadline'); + assert.throws(() => throwIfRequestCanceled('completed-deadline'), isRequestCanceledError); + } finally { + clearRequestAbortRegistration(registration); + vi.useRealTimers(); + } +}); + +test.each(['timer', 'wall-clock'])( + 'a late completed response stays successful after the %s deadline', + async (clock) => { + vi.useFakeTimers(); + try { + const response: DaemonResponse = { + ok: true, + data: { executed: 3, results: ['press', 'fill', 'longpress'] }, + }; + const result = await withRequestDeadline('late-success', 100, async () => { + if (clock === 'timer') await vi.advanceTimersByTimeAsync(101); + else vi.setSystemTime(Date.now() + 101); + return response; + }); + assert.equal(result, response); + assert.equal(getRequestSignal('late-success'), undefined); + } finally { + vi.useRealTimers(); + } + }, +); + +test('deadline failure retains response diagnostics and progress while adding timeout metadata', async () => { + vi.useFakeTimers(); + try { + const response: DaemonResponse = { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'Batch failed at step 3', + hint: 'Inspect the failed step', + diagnosticId: 'diag-3', + logPath: '/tmp/request.log', + retriable: false, + details: { + executed: 2, + partialResults: [{ command: 'press' }, { command: 'fill' }], + dispatched: 'unknown', + reason: 'transport_aborted', + }, + }, + }; + const result = await withRequestDeadline('failed-response', 100, async () => { + await vi.advanceTimersByTimeAsync(100); + return response; + }); + assert.equal(result.ok, false); + if (result.ok) assert.fail('deadline failure became a success'); + assert.equal(result.error.code, 'COMMAND_FAILED'); + assert.equal(result.error.message, 'Batch failed at step 3'); + assert.equal(result.error.hint, 'Inspect the failed step'); + assert.equal(result.error.diagnosticId, 'diag-3'); + assert.equal(result.error.logPath, '/tmp/request.log'); + assert.equal(result.error.retriable, false); + assert.equal(result.error.details?.executed, 2); + assert.deepEqual(result.error.details?.partialResults, [ + { command: 'press' }, + { command: 'fill' }, + ]); + assert.equal(result.error.details?.dispatched, 'unknown'); + assert.equal(result.error.details?.reason, 'daemon_request_timeout'); + assert.equal(result.error.details?.causeReason, 'transport_aborted'); + assert.equal(result.error.details?.timeoutMs, 100); + } finally { + vi.useRealTimers(); + } +}); + +test('a thrown error before the deadline retains its identity', async () => { + const error = new AppError('INVALID_ARGS', 'Invalid replay'); + await assert.rejects( + withRequestDeadline('early-throw', 100, async () => { + throw error; + }), + (caught) => caught === error, + ); +}); + +test.each([false, true])( + 'a throw at the wall-clock cutoff observes the deadline and preserves prior cancellation=%s', + async (canceled) => { + vi.useFakeTimers(); + const requestId = `wall-clock-throw-${canceled}`; + const registration = registerRequestAbort(requestId); + try { + const signal = getRequestSignal(requestId)!; + await assert.rejects( + withRequestDeadline(requestId, 100, async () => { + if (canceled) markRequestCanceled(requestId); + vi.setSystemTime(Date.now() + 100); + assert.equal(signal.aborted, canceled); + throw new Error('synchronous operation failed after blocking the event loop'); + }), + (error: unknown) => { + assert.equal(error, signal.reason); + if (canceled) return isRequestCanceledError(error); + return ( + error instanceof AppError && + error.details?.reason === 'daemon_request_timeout' && + error.details.timeoutMs === 100 + ); + }, + ); + assert.equal(signal.aborted, true); + assert.equal(getRequestSignal(requestId), signal); + assert.equal(vi.getTimerCount(), 0); + } finally { + clearRequestAbortRegistration(registration); + vi.useRealTimers(); + } + }, +); + +test('explicit cancellation still rejects a settled response without deadline metadata', async () => { + await assert.rejects( + withRequestDeadline('canceled-response', 100, async () => { + markRequestCanceled('canceled-response'); + return { ok: true }; + }), + isRequestCanceledError, + ); +}); + test('createRequestCanceledError includes stable cancellation reason marker', () => { const err = createRequestCanceledError(); assert.equal(err.code, 'COMMAND_FAILED'); diff --git a/packages/host-kit/src/internal/request-cancel.ts b/packages/host-kit/src/internal/request-cancel.ts index 0d9fb5dc0f..f720527d1b 100644 --- a/packages/host-kit/src/internal/request-cancel.ts +++ b/packages/host-kit/src/internal/request-cancel.ts @@ -1,4 +1,5 @@ import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; +import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { runtimeSlot } from '../runtime-slots.ts'; type RequestCancelState = { @@ -83,7 +84,7 @@ export function registerRequestAbort( const controller = new AbortController(); requestAbortControllers().set(requestId, controller); if (canceledRequestIds().has(requestId)) { - controller.abort(); + controller.abort(createRequestCanceledError()); } return { requestId, controller }; } @@ -129,7 +130,7 @@ export function clearRequestAbortRegistration( export function isRequestCanceled(requestId: string | undefined): boolean { if (!requestId) return false; - return canceledRequestIds().has(requestId); + return canceledRequestIds().has(requestId) || getRequestSignal(requestId)?.aborted === true; } export function getRequestSignal(requestId: string | undefined): AbortSignal | undefined { @@ -138,7 +139,61 @@ export function getRequestSignal(requestId: string | undefined): AbortSignal | u } export function throwIfRequestCanceled(requestId: string | undefined): void { + getRequestSignal(requestId)?.throwIfAborted(); if (isRequestCanceled(requestId)) { throw createRequestCanceledError(); } } + +/** Keeps a daemon deadline on the request's existing cancellation signal until its work settles. */ +export async function withRequestDeadline( + requestId: string, + timeoutMs: number, + task: () => Promise, +): Promise { + const ownedRegistration = requestAbortControllers().has(requestId) + ? undefined + : registerRequestAbort(requestId); + const controller = requestAbortControllers().get(requestId)!; + const deadlineMs = Date.now() + timeoutMs; + const deadlineError = new AppError('COMMAND_FAILED', 'Daemon request deadline exceeded', { + reason: 'daemon_request_timeout', + timeoutMs, + requestId, + }); + const expire = () => controller.abort(deadlineError); + const timer = setTimeout(expire, timeoutMs); + try { + controller.signal.throwIfAborted(); + const result = await task(); + if (Date.now() >= deadlineMs) expire(); + if (controller.signal.reason === deadlineError) { + return result.ok + ? result + : { + ...result, + error: { + ...result.error, + details: { + ...result.error.details, + ...(typeof result.error.details?.reason === 'string' && + result.error.details.reason !== 'daemon_request_timeout' + ? { causeReason: result.error.details.reason } + : {}), + reason: 'daemon_request_timeout', + timeoutMs, + }, + }, + }; + } + controller.signal.throwIfAborted(); + return result; + } catch (error) { + if (Date.now() >= deadlineMs) expire(); + controller.signal.throwIfAborted(); + throw error; + } finally { + clearTimeout(timer); + clearRequestAbortRegistration(ownedRegistration); + } +} diff --git a/packages/host-kit/src/request.ts b/packages/host-kit/src/request.ts index 241f236297..ec1fa1a063 100644 --- a/packages/host-kit/src/request.ts +++ b/packages/host-kit/src/request.ts @@ -8,5 +8,6 @@ export { registerRequestAbort, resolveRequestTrackingId, throwIfRequestCanceled, + withRequestDeadline, } from './internal/request-cancel.ts'; export { emitRequestProgress, withRequestProgressSink } from './internal/request-progress.ts'; diff --git a/packages/kernel/src/keyed-lock.test.ts b/packages/kernel/src/keyed-lock.test.ts index 610aa9e54a..302be03472 100644 --- a/packages/kernel/src/keyed-lock.test.ts +++ b/packages/kernel/src/keyed-lock.test.ts @@ -2,6 +2,67 @@ import { test, expect, vi } from 'vitest'; import assert from 'node:assert/strict'; import { withKeyedLock } from './keyed-lock.ts'; +test('canceled queued work returns before its owner and never runs or admits a competing task early', async () => { + const locks = new Map>(); + const controller = new AbortController(); + let release!: () => void; + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const owner = withKeyedLock(locks, 'session', async () => { + markStarted(); + await new Promise((resolve) => { + release = resolve; + }); + }); + await started; + const expiredTask = vi.fn(async () => {}); + const queued = withKeyedLock(locks, 'session', expiredTask, controller.signal); + const reason = new Error('deadline'); + controller.abort(reason); + await assert.rejects(queued, (error) => error === reason); + const competingTask = vi.fn(async () => {}); + const competing = withKeyedLock(locks, 'session', competingTask); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(competingTask.mock.calls.length, 0); + release(); + await Promise.all([owner, competing]); + assert.equal(expiredTask.mock.calls.length, 0); + assert.equal(competingTask.mock.calls.length, 1); + assert.equal(locks.size, 0); +}); + +test('an abort after lock admission waits for the running task to settle', async () => { + const locks = new Map>(); + const controller = new AbortController(); + let release!: () => void; + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let settled = false; + const running = withKeyedLock( + locks, + 'session', + async () => { + markStarted(); + await new Promise((resolve) => { + release = resolve; + }); + }, + controller.signal, + ).finally(() => { + settled = true; + }); + await started; + controller.abort(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(settled, false); + release(); + await running; +}); + test('withKeyedLock serializes work per key', async () => { const locks = new Map>(); const order: string[] = []; diff --git a/packages/kernel/src/keyed-lock.ts b/packages/kernel/src/keyed-lock.ts index c8a5e55b06..b7ffcb0e55 100644 --- a/packages/kernel/src/keyed-lock.ts +++ b/packages/kernel/src/keyed-lock.ts @@ -13,7 +13,9 @@ export async function withKeyedLock( locks: Map>, key: string, task: () => Promise, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const activeLocks = keyedLockStorage.getStore() ?? []; const held = activeLocks.find( (entry) => entry.active && entry.locks === locks && entry.key === key, @@ -32,9 +34,12 @@ export async function withKeyedLock( } } const previous = locks.get(key) ?? Promise.resolve(); + let entered = false; const current = previous .catch(() => {}) .then(async () => { + entered = true; + signal?.throwIfAborted(); const frame: LockFrame = { locks, key, active: true, descendants: new Set() }; try { return await keyedLockStorage.run( @@ -47,9 +52,18 @@ export async function withKeyedLock( } }); locks.set(key, current); - return current.finally(() => { + const completion = current.finally(() => { if (locks.get(key) === current) { locks.delete(key); } }); + if (!signal) return await completion; + return await new Promise((resolve, reject) => { + const onAbort = () => { + if (!entered) reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + completion.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + if (signal.aborted) onAbort(); + }); } diff --git a/packages/platform-android/src/__tests__/snapshot-helper-retirement.test.ts b/packages/platform-android/src/__tests__/snapshot-helper-retirement.test.ts index e02eed596a..824094c57f 100644 --- a/packages/platform-android/src/__tests__/snapshot-helper-retirement.test.ts +++ b/packages/platform-android/src/__tests__/snapshot-helper-retirement.test.ts @@ -8,7 +8,7 @@ import { recoverAndroidSnapshotHelperRetirement, recordAndroidSnapshotHelperRelease, resetAndroidSnapshotHelperRetirements, - retireCanceledAndroidSnapshotHelperCapture, + retireCanceledAndroidSnapshotHelperCommand, settleAndroidSnapshotHelperSessionCleanup, } from '../snapshot-helper-retirement.ts'; import type { AndroidAdbExecutorResult, AndroidAdbProcess } from '../adb-executor.ts'; @@ -44,7 +44,7 @@ test('canceled capture answers for the device, not for the force-stop call that // A loaded host makes the stop call time out while the helper process is already gone. Ownership // is decided by the process Android reports, so this retirement resolves. - await retireCanceledAndroidSnapshotHelperCapture({ + await retireCanceledAndroidSnapshotHelperCommand({ deviceKey: DEVICE_KEY, packageName: PACKAGE_NAME, adb, @@ -67,7 +67,7 @@ test('unproven release stays pending until an acquire reads the device', async ( return { exitCode: 0, stdout: '', stderr: '' }; }; - await retireCanceledAndroidSnapshotHelperCapture({ + await retireCanceledAndroidSnapshotHelperCommand({ deviceKey: DEVICE_KEY, packageName: PACKAGE_NAME, adb, diff --git a/packages/platform-android/src/__tests__/touch-helper-session.test.ts b/packages/platform-android/src/__tests__/touch-helper-session.test.ts index ee62c00ad5..854b1f8de7 100644 --- a/packages/platform-android/src/__tests__/touch-helper-session.test.ts +++ b/packages/platform-android/src/__tests__/touch-helper-session.test.ts @@ -93,7 +93,7 @@ class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess { } } -type TouchSessionCommandHandler = (command: string, requestId: string) => string; +type TouchSessionCommandHandler = (command: string, requestId: string) => string | undefined; function readSessionPort(args: readonly string[]): number { const index = args.indexOf('sessionPort'); @@ -170,7 +170,8 @@ function createFakeTouchHelperSessionProvider( server.close(() => process.emitExit(0, null)); return; } - socket.end(handleCommand(command, requestId)); + const response = handleCommand(command, requestId); + if (response !== undefined) socket.end(response); }); }); server.listen(port, '127.0.0.1', () => { @@ -192,6 +193,62 @@ function createFakeTouchHelperSessionProvider( }; } +test('aborting an in-flight persistent gesture retires its helper without one-shot replay', async () => { + const device = makeIsolatedDevice(); + const controller = new AbortController(); + let entered!: () => void; + const injecting = new Promise((resolve) => { + entered = resolve; + }); + const provider = createFakeTouchHelperSessionProvider((command, requestId) => { + if (command.startsWith('gesture')) { + entered(); + return undefined; + } + return sessionHeaderResponse({ + agentDeviceProtocol: 'android-snapshot-helper-v1', + requestId, + ok: 'true', + x: '0', + y: '0', + width: '400', + height: '800', + }); + }); + let retired = false; + let oneShot = false; + await withAndroidAdbProvider( + { + ...provider, + exec: currentVersionAdb(async (args, options) => { + if (args.includes('force-stop')) retired = true; + if (args.includes('instrument')) oneShot = true; + return await provider.exec(args, options); + }), + }, + { serial: device.id }, + async () => { + await readAndroidTouchHelperViewportReading(device, { helperSessionScope: 'daemon-session' }); + const pending = executeAndroidTouchHelperPlan( + device, + lowerAndroidTouchPlan(flingPlan()), + controller.signal, + ); + await injecting; + const reason = new AppError('COMMAND_FAILED', 'deadline', { + reason: 'daemon_request_timeout', + }); + controller.abort(reason); + await assert.rejects( + pending, + (error) => error instanceof AppError && error.details?.reason === 'daemon_request_timeout', + ); + assert.equal(retired, true); + assert.equal(oneShot, false); + }, + ); +}); + test('touch helper does not run one-shot while the device still runs the helper', async () => { const device = makeIsolatedDevice(); const deviceKey = getAndroidSnapshotHelperSessionDeviceKey(device); diff --git a/packages/platform-android/src/automation-helper-artifact.ts b/packages/platform-android/src/automation-helper-artifact.ts index 215462dffb..a6786528eb 100644 --- a/packages/platform-android/src/automation-helper-artifact.ts +++ b/packages/platform-android/src/automation-helper-artifact.ts @@ -46,7 +46,9 @@ export function androidAutomationHelperIdentity(artifact: AndroidSnapshotHelperA export async function prepareAndroidAutomationHelper( device: DeviceInfo, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const adbProvider = resolveAndroidAdbProvider(device); // Same artifact precedence as snapshot capture: a provider-supplied helper artifact overrides // the bundled one so both transports install and run the single shared helper (issue #1275). @@ -56,6 +58,7 @@ export async function prepareAndroidAutomationHelper( await recoverAndroidSnapshotHelperRetirement({ deviceKey, adb: adbProvider.exec, + signal, }); const install = await withDiagnosticTimer( 'android_touch_helper_install', @@ -66,6 +69,7 @@ export async function prepareAndroidAutomationHelper( artifact, deviceKey, timeoutMs: HELPER_INSTALL_TIMEOUT_MS, + signal, }), { packageName: artifact.manifest.packageName, @@ -79,8 +83,9 @@ export async function prepareAndroidAutomationHelper( if (install.installed) { // An APK replacement kills the running instrumentation, so a persistent session started // against the previous binary must not serve this command; the next snapshot restarts it. - await stopAndroidSnapshotHelperSession(deviceKey); + await stopAndroidSnapshotHelperSession(deviceKey, { signal }); } + signal?.throwIfAborted(); return { adb: adbProvider.exec, adbProvider, artifact, install, deviceKey }; } diff --git a/packages/platform-android/src/input-actions.ts b/packages/platform-android/src/input-actions.ts index fc15c8727c..a2a3b500c1 100644 --- a/packages/platform-android/src/input-actions.ts +++ b/packages/platform-android/src/input-actions.ts @@ -288,22 +288,27 @@ export async function longPressAndroid( x: number, y: number, durationMs = 800, + signal?: AbortSignal, ): Promise> { const point = { x, y }; - return await executeAndroidTouchPlan(device, { - topology: 'single', - intent: 'longPress', - durationMs, - pointers: [ - { - pointerId: 0, - samples: [ - { offsetMs: 0, point }, - { offsetMs: durationMs, point }, - ], - }, - ], - }); + return await executeAndroidTouchPlan( + device, + { + topology: 'single', + intent: 'longPress', + durationMs, + pointers: [ + { + pointerId: 0, + samples: [ + { offsetMs: 0, point }, + { offsetMs: durationMs, point }, + ], + }, + ], + }, + signal, + ); } export async function focusAndroid(device: DeviceInfo, x: number, y: number): Promise { diff --git a/packages/platform-android/src/snapshot-helper-capture.ts b/packages/platform-android/src/snapshot-helper-capture.ts index 96f97cd68a..a23c9f7f3c 100644 --- a/packages/platform-android/src/snapshot-helper-capture.ts +++ b/packages/platform-android/src/snapshot-helper-capture.ts @@ -33,7 +33,7 @@ import type { import type { AndroidAdbProvider } from './adb-executor.ts'; import { recoverAndroidSnapshotHelperRetirement, - retireCanceledAndroidSnapshotHelperCapture, + retireCanceledAndroidSnapshotHelperCommand, } from './snapshot-helper-retirement.ts'; type AndroidSnapshotHelperChunk = { @@ -87,7 +87,7 @@ export async function captureAndroidSnapshotWithHelper( ); } catch (error) { if (!options.signal?.aborted) throw error; - await retireCanceledAndroidSnapshotHelperCapture({ + await retireCanceledAndroidSnapshotHelperCommand({ deviceKey, packageName: resolved.packageName, adb: options.adb, @@ -97,7 +97,7 @@ export async function captureAndroidSnapshotWithHelper( throw error; } if (options.signal?.aborted) { - await retireCanceledAndroidSnapshotHelperCapture({ + await retireCanceledAndroidSnapshotHelperCommand({ deviceKey, packageName: resolved.packageName, adb: options.adb, diff --git a/packages/platform-android/src/snapshot-helper-retirement.ts b/packages/platform-android/src/snapshot-helper-retirement.ts index 8e58094b8d..9336a07cdd 100644 --- a/packages/platform-android/src/snapshot-helper-retirement.ts +++ b/packages/platform-android/src/snapshot-helper-retirement.ts @@ -49,11 +49,11 @@ export function getAndroidSnapshotHelperSessionDeviceKey( } /** - * Stops the runtime a canceled one-shot capture left behind and records what could not be proven. + * Stops the runtime a canceled one-shot helper command left behind and records what could not be proven. * The caller's own outcome — usually a cancellation — is what the caller reports; ownership is a * device fact recovered before the next command starts work, not a reason to fail this one. */ -export async function retireCanceledAndroidSnapshotHelperCapture(params: { +export async function retireCanceledAndroidSnapshotHelperCommand(params: { deviceKey: string; packageName: string; adb: AndroidAdbExecutor; diff --git a/packages/platform-android/src/snapshot-helper-session.ts b/packages/platform-android/src/snapshot-helper-session.ts index b57fa9f91b..3ab0bdd6f2 100644 --- a/packages/platform-android/src/snapshot-helper-session.ts +++ b/packages/platform-android/src/snapshot-helper-session.ts @@ -125,7 +125,9 @@ export async function runAndroidSnapshotHelperSessionTouchCommand(params: { helper: AndroidSnapshotHelperSessionHelperIdentity; payloadBase64?: string; timeoutMs: number; + signal?: AbortSignal; }): Promise | undefined> { + params.signal?.throwIfAborted(); const session = await getLiveAndroidSnapshotHelperSession(params.deviceKey); if (!session) return undefined; if (!matchesSessionHelperIdentity(session.helper, params.helper)) { @@ -154,12 +156,16 @@ export async function runAndroidSnapshotHelperSessionTouchCommand(params: { session.port, command, params.timeoutMs, + params.signal, ); headers = parseAndroidSnapshotHelperSessionTouchResponse(response, requestId); } catch (error) { // Transport-level failure: the session process can no longer be trusted. Stop it so the next // command runs against a fresh helper instead of a wedged socket. - await stopAndroidSnapshotHelperSession(params.deviceKey); + await stopAndroidSnapshotHelperSession(params.deviceKey, { + signal: params.signal, + cause: error, + }); throw discloseHelperTouchDispatch(params.action, error); } if (headers.ok !== 'true') { diff --git a/packages/platform-android/src/touch-executor.ts b/packages/platform-android/src/touch-executor.ts index a88572a097..13606f2dce 100644 --- a/packages/platform-android/src/touch-executor.ts +++ b/packages/platform-android/src/touch-executor.ts @@ -15,7 +15,9 @@ import type { AndroidHelperSessionOptions } from './snapshot-helper-types.ts'; export async function executeAndroidTouchPlan( device: DeviceInfo, plan: AndroidTouchPlan, + signal?: AbortSignal, ): Promise> { + signal?.throwIfAborted(); const loweredPlan = lowerAndroidTouchPlan(plan); const provider = resolveAndroidTouchProvider(device); if (provider) { @@ -29,7 +31,7 @@ export async function executeAndroidTouchPlan( const result = (await provider.touch(providerPlan)) ?? {}; return { backend: 'provider-native-touch', ...result }; } - return await executeAndroidTouchHelperPlan(device, loweredPlan); + return await executeAndroidTouchHelperPlan(device, loweredPlan, signal); } export async function readAndroidGestureViewport( diff --git a/packages/platform-android/src/touch-helper.ts b/packages/platform-android/src/touch-helper.ts index 2dd0b450dc..3ad1c7b616 100644 --- a/packages/platform-android/src/touch-helper.ts +++ b/packages/platform-android/src/touch-helper.ts @@ -16,6 +16,7 @@ import { type AndroidGestureViewportReading, } from './gesture-viewport.ts'; import type { AndroidLoweredTouchPlan } from './touch-plan-lowering.ts'; +import { retireCanceledAndroidSnapshotHelperCommand } from './snapshot-helper-retirement.ts'; import { discloseHelperTouchDispatch, runAndroidSnapshotHelperSessionTouchCommand, @@ -52,10 +53,11 @@ type AndroidTouchHelperGestureRequest = { export async function executeAndroidTouchHelperPlan( device: DeviceInfo, plan: AndroidLoweredTouchPlan, + signal?: AbortSignal, ): Promise> { const { prepareAndroidAutomationHelper, androidAutomationHelperIdentity } = await import('./automation-helper-artifact.ts'); - const prepared = await prepareAndroidAutomationHelper(device); + const prepared = await prepareAndroidAutomationHelper(device, signal); const request = normalizeAndroidTouchHelperGestureRequest(plan); const timeoutMs = Math.max( HELPER_GESTURE_TIMEOUT_MS, @@ -76,6 +78,7 @@ export async function executeAndroidTouchHelperPlan( helper: androidAutomationHelperIdentity(prepared.artifact), payloadBase64, timeoutMs, + signal, }); if (sessionHeaders) { return { ...readGestureResult(sessionHeaders), helperTransport: 'persistent-session' }; @@ -87,6 +90,14 @@ export async function executeAndroidTouchHelperPlan( action: 'gesture', extraArgs: ['-e', 'payloadBase64', payloadBase64], timeoutMs, + signal, + retireCanceled: async (cause) => + await retireCanceledAndroidSnapshotHelperCommand({ + deviceKey: prepared.deviceKey, + packageName: prepared.artifact.manifest.packageName, + adb: prepared.adb, + cause, + }), readResult: readGestureResult, })), helperTransport: 'instrumentation', @@ -184,12 +195,23 @@ async function runOneShotTouchHelper(options: { extraArgs: string[]; timeoutMs: number; readResult: (record: Record) => Result; + signal?: AbortSignal; + retireCanceled?: (cause: unknown) => Promise; }): Promise { const result = await runAdbShell( options.adb, ['am', 'instrument', '-w', '-e', 'mode', options.action, ...options.extraArgs, options.runner], - { allowFailure: true, timeoutMs: options.timeoutMs }, - ); + { allowFailure: true, timeoutMs: options.timeoutMs, signal: options.signal }, + ).catch(async (error) => { + if (!options.signal?.aborted) throw error; + await options.retireCanceled?.(error); + options.signal.throwIfAborted(); + throw error; + }); + if (options.signal?.aborted) { + await options.retireCanceled?.(options.signal.reason); + options.signal.throwIfAborted(); + } let finalRecord: Record; try { finalRecord = readAndroidTouchHelperFinalRecord(`${result.stdout}\n${result.stderr}`); diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index 3b1bad5492..9b43dd4f4f 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -117,10 +117,10 @@ test('budget sources deviating from the default are bounded, reviewed sets', () } } // --timeout bounds the request envelope for these commands only. - assert.deepEqual(flagBoundBudget.sort(), ['replay', 'snapshot']); + assert.deepEqual(flagBoundBudget.sort(), ['snapshot']); // --timeout is a daemon-side startup budget on these commands (#2324, #3004); the // envelope keeps a margin over it so the daemon's own timeout wins the race. - assert.deepEqual(flagMarginBudget.sort(), ['boot', 'open', 'prepare']); + assert.deepEqual(flagMarginBudget.sort(), ['boot', 'open', 'prepare', 'replay']); // --timeout bounds the --settle wait on these commands (#1101); like wait's // positional budget it only ever widens the envelope, never shrinks it. assert.deepEqual(flagWidenBudget.sort(), settleObservationCommandNames()); diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index 04f090ad9d..ce89c3343c 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -53,6 +53,7 @@ function buildBatchCliDetail(): string { const prose = [ 'Each step is {"command":"","input":{...}}, and input is that command\'s structured input object rather than its terminal spelling: a CLI positional becomes a named field (target, text, direction) and a CLI flag becomes a camelCase key (--settle -> "settle":true, -i -> "interactiveOnly":true). There is no positional step form; args, argv, positionals, and flags are not step fields. agent-device help lists arguments in CLI spelling only. The examples below carry the structured spelling; over MCP, the command tool schema states it in full.', 'Steps run serially in one daemon request against the same session, in order. Mutating UI verbs are included (press, click, fill, longpress, scroll, back), which is where the round-trip saving is; --on-error stop halts at the first failing step.', + 'The whole batch has a 60-second wall-clock budget. A step that settles after cancellation returns COMMAND_FAILED with details.reason daemon_request_timeout; the daemon and its sessions remain available. Failure preserves executed, partialResults, and dispatch status. A completed batch returns success even if it settles just after the deadline. A step that ignores cancellation can delay the result past the client timeout. Split longer sequences into separate requests.', `Available through batch: ${[...STRUCTURED_BATCH_COMMAND_NAMES].sort().join(', ')}.`, 'Every other command is excluded: batch and replay never nest, and session, daemon, connection, and host tooling commands own lifecycle a batch request cannot carry. Run those on their own.', ].join(' '); diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index 0706832a7e..ba55b60ea4 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -66,7 +66,9 @@ export const replayCommandMetadata = defineFieldCommandMetadata( keepSession: booleanField( 'Leave the session active by suppressing exactly an authored terminal close in native .ad.', ), - timeoutMs: integerField('Maximum wall-clock duration for the replay request.'), + timeoutMs: integerField( + 'Operation wall-clock budget in milliseconds (default 60000). The client allows another 30000 milliseconds for the daemon result.', + ), // ADR 0012 decision 6, R1/R6: arms agent-supervised re-record repair // from the first replay attempt; optional string value is the healed // script's output path. @@ -297,7 +299,7 @@ export const replayCommandFacet = defineCommandFacet({ text: { summary: 'Replay a recorded session or Maestro flow', cliDetail: - 'For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively. For native .ad scripts, --keep-session suppresses exactly an authored terminal close so you can continue interactively. replay export converts compatible actions to Maestro YAML locally, including app switches with explicit launchApp.appId targets, deep links (including tel: and mailto:) as openLink, and home as pressKey: Home.', + 'Replay has a 60-second wall-clock budget; --timeout changes it. A step that settles after cancellation returns a typed failure with details.reason daemon_request_timeout and details.timeoutMs; the daemon and its sessions remain available. Failure preserves the failing script path and line. A completed replay returns success even if it settles just after the deadline. The client allows another 30 seconds for the result. A step that ignores cancellation can delay the result past the client timeout. For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively. For native .ad scripts, --keep-session suppresses exactly an authored terminal close so you can continue interactively. replay export converts compatible actions to Maestro YAML locally, including app switches with explicit launchApp.appId targets, deep links (including tel: and mailto:) as openLink, and home as pressKey: Home.', }, metadata: replayCommandMetadata, run: (client, input) => client.replay.run(withCommandRuntimeHints(input)), diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index b656f55e61..c92cecabc3 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -83,13 +83,14 @@ export function createAndroidInteractor( close: (app) => closeAndroidApp(device, app), tap: (x, y) => pressAndroid(device, x, y), doubleTap: (x, y) => doubleTapAndroid(device, x, y), - longPress: (x, y, durationMs) => longPressAndroid(device, x, y, durationMs), + longPress: (x, y, durationMs) => + longPressAndroid(device, x, y, durationMs, runnerContext?.signal), focus: (x, y) => focusAndroid(device, x, y), type: (text, delayMs) => typeAndroid(device, text, delayMs), fill: (x, y, text, delayMs) => fillAndroid(device, x, y, text, delayMs, { helperSessionScope }), scroll: (direction, options) => scrollAndroid(device, direction, { ...options, helperSessionScope }), - performGesture: (plan) => executeAndroidTouchPlan(device, plan), + performGesture: (plan) => executeAndroidTouchPlan(device, plan, runnerContext?.signal), gestureViewport: () => readAndroidGestureViewport(device, { helperSessionScope }), screenshot: (outPath, options) => screenshotAndroid(device, outPath, options), readTextAtPoint: async (point, options) => { diff --git a/test/integration/provider-scenarios/composite-deadline.test.ts b/test/integration/provider-scenarios/composite-deadline.test.ts new file mode 100644 index 0000000000..b377be2720 --- /dev/null +++ b/test/integration/provider-scenarios/composite-deadline.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../../src/__tests__/test-utils/android-snapshot-helper.ts'; +import { createProviderScenarioHarness } from './harness.ts'; +import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; +import { respondToAndroidSettingsAdbCommand } from './android-world.ts'; +import { assertRpcOk } from './assertions.ts'; + +test.each(['batch', 'replay'])( + '%s deadline cancels Android longpress instrumentation and preserves its session', + async (command) => { + let started!: () => void; + const injecting = new Promise((resolve) => { + started = resolve; + }); + let observedFill!: () => void; + const fillObservation = new Promise((resolve) => { + observedFill = resolve; + }); + let signal: AbortSignal | undefined; + let retired = false; + const completedActions: string[] = []; + let searchText = ''; + const daemon = await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + env: { AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION: '0' }, + androidAdbProvider: () => ({ + snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + text: async (request) => { + searchText = request.text; + completedActions.push('fill'); + }, + exec: async (args, options) => { + if (args.join(' ').includes('AGENT_DEVICE_NO_HELPER')) + return { exitCode: 0, stdout: 'AGENT_DEVICE_NO_HELPER\n', stderr: '' }; + if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'tap') { + completedActions.push('press'); + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args.includes('instrument') && args.includes('gesture')) { + signal = options?.signal; + started(); + assert.ok(signal, 'longpress must carry the request signal to its helper'); + return await new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }); + }); + } + if (args.includes('force-stop')) { + if (signal?.aborted) retired = true; + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args.includes('--show-versioncode')) + return { + exitCode: 0, + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:999999', + stderr: '', + }; + if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'start') + return { exitCode: 0, stdout: 'Starting: Intent', stderr: '' }; + if (searchText && args.includes('instrument')) observedFill(); + return respondToAndroidSettingsAdbCommand([...args], searchText, '', {}); + }, + }), + }); + try { + await daemon.client().apps.open({ + app: 'com.android.settings', + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + const script = path.join(daemon.stateDir, 'hold.ad'); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + const timeoutMs = command === 'batch' ? 60_000 : 1_000; + const pending = daemon.callCommand(command, command === 'replay' ? [script] : [], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + ...(command === 'batch' + ? { + batchSteps: [ + { command: 'press', positionals: ['20', '30'] }, + { + command: 'fill', + positionals: ['20', '40', 'deadline proof'], + }, + { command: 'longpress', positionals: ['20', '30', '120000'] }, + ], + } + : { + timeoutMs, + replayScriptSource: { + entry: script, + files: { + [script]: + '# completed actions must not be replayed\npress 20 30\nfill 20 40 "deadline proof"\nlongpress 20 30 120000\n', + }, + }, + }), + }); + await fillObservation; + await vi.advanceTimersByTimeAsync(150); + await Promise.race([ + injecting, + pending.then((response) => assert.fail(JSON.stringify(response.json))), + ]); + assert.deepEqual(completedActions, ['press', 'fill']); + await vi.advanceTimersByTimeAsync(timeoutMs); + const response = await pending; + assert.equal( + response.json.error?.data?.details?.reason, + 'daemon_request_timeout', + JSON.stringify(response.json), + ); + assert.equal(response.json.error.data.details.timeoutMs, timeoutMs); + if (command === 'batch') { + expect(response.json.error.data.details).toMatchObject({ + executed: 2, + total: 3, + step: 3, + command: 'longpress', + dispatched: 'unknown', + partialResults: [ + { step: 1, command: 'press', ok: true }, + { step: 2, command: 'fill', ok: true }, + ], + }); + } else { + expect(response.json.error.data.details).toMatchObject({ + divergence: { step: { source: { path: script, line: 4 } } }, + }); + } + assert.equal(signal?.aborted, true); + assert.equal( + retired, + true, + 'canceled one-shot instrumentation must release its device runtime', + ); + vi.useRealTimers(); + assert.equal(await daemon.hasSession(), true); + assertRpcOk(await daemon.callCommand('snapshot', [], { snapshotInteractiveOnly: true })); + } finally { + vi.useRealTimers(); + await daemon.close(); + } + }, + 20_000, +); diff --git a/test/integration/provider-scenarios/ios-fold.test.ts b/test/integration/provider-scenarios/ios-fold.test.ts index ea3ed76d20..893aa8c3db 100644 --- a/test/integration/provider-scenarios/ios-fold.test.ts +++ b/test/integration/provider-scenarios/ios-fold.test.ts @@ -3,12 +3,19 @@ import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/t import { MAX_FOLD_DURATION_MS } from '@agent-device/contracts/device'; import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; import type { ExecResult } from '@agent-device/host-kit/command'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import { normalizeError } from '@agent-device/kernel/errors'; +import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { createTestHostEnvironment } from '@agent-device/test-kit'; +import { sendRequest } from '../../../src/daemon-client/daemon-client-transport.ts'; +import { setTimeout as realSleep } from 'node:timers/promises'; import { assertRpcError, assertRpcOk } from './assertions.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, beforeEach, test, vi } from 'vitest'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { createProviderScenarioHarness, type ProviderScenarioRpcResult } from './harness.ts'; import { createRecordingAppleToolProvider } from './providers.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; @@ -168,7 +175,7 @@ function alternatingHingeAngle(readIndex: number): number { function createFoldLedgerAppleToolProvider(params: { hingeAngleAt: (readIndex: number) => number; - onCall: (call: FoldLedgerCall) => void; + onCall: (call: FoldLedgerCall, signal?: AbortSignal) => void | Promise; isTracking: () => boolean; }): { provider: AppleToolProvider; ledger: FoldLedgerCall[]; hingeReadCount: () => number } { const ledger: FoldLedgerCall[] = []; @@ -178,11 +185,11 @@ function createFoldLedgerAppleToolProvider(params: { // core/tool-provider.ts, core/simctl.ts) carries a bounded timeoutMs. A call reaching this fake // with none is not a worst case the envelope assertion below can see, so it must fail the test // rather than cost 0 virtual ms. - const record = ( + const record = async ( tool: FoldLedgerCall['tool'], args: readonly string[], - options?: { timeoutMs?: number; kill?: { graceMs: number } }, - ): void => { + options?: { timeoutMs?: number; kill?: { graceMs: number }; signal?: AbortSignal }, + ): Promise => { assert.ok( Number.isFinite(options?.timeoutMs) && options!.timeoutMs! > 0, `fold ledger call has no bounded timeoutMs: ${tool} ${args.join(' ')}`, @@ -194,14 +201,14 @@ function createFoldLedgerAppleToolProvider(params: { ...(options?.kill ? { graceMs: options.kill.graceMs } : {}), }; ledger.push(call); - params.onCall(call); + await params.onCall(call, options?.signal); }; // Built on the shared recording provider so any call this route does not script (macosHelper, // macosHost, plist, or an unexpected runCommand) throws instead of being silently answered. const recording = createRecordingAppleToolProvider({ simctl: async (args, options) => { if (args[0] === 'list') { - if (params.isTracking()) record('simctl', args, options); + if (params.isTracking()) await record('simctl', args, options); return { stdout: JSON.stringify({ devices: { @@ -213,11 +220,11 @@ function createFoldLedgerAppleToolProvider(params: { }; } if (args[0] === 'launch') return ok; - record('simctl', args, options); + await record('simctl', args, options); return ok; }, devicectl: async (args, options) => { - record('devicectl', args, options); + await record('devicectl', args, options); if (args.includes('hinge-angle')) { const angle = params.hingeAngleAt(hingeReads); hingeReads += 1; @@ -231,7 +238,7 @@ function createFoldLedgerAppleToolProvider(params: { const provider: AppleToolProvider = { ...recording.provider, runCommand: async (cmd, args, options) => { - record('runCommand', [cmd, ...args], options); + await record('runCommand', [cmd, ...args], options); const probeAnswer = toolchainProbeAnswer(cmd, args); if (probeAnswer) return probeAnswer; assert.equal(cmd, 'xcrun', `unexpected runCommand call: ${cmd} ${args.join(' ')}`); @@ -398,3 +405,176 @@ test('fold worst-case ledger covers the client envelope with the daemon-result m `exceeds the ${envelopeMs}ms envelope.\nLedger:\n${JSON.stringify(measured.ledger, null, 2)}`, ); }, 20_000); + +test.each([ + { command: 'batch' }, + { command: 'replay' }, + { command: 'replay', timeoutMs: 1_000 }, + { command: 'replay', timeoutMs: 120_000 }, + { command: 'replay', timeoutMs: 300_000 }, +] as const)( + '$command worst-case fold returns a daemon result (timeout=$timeoutMs)', + async ({ command, ...options }) => { + const timeoutMs = 'timeoutMs' in options ? options.timeoutMs : undefined; + const calibration = await runFoldLedgerScenario({ + hingeAngleAt: alternatingHingeAngle, + withVirtualClock: false, + }); + assert.equal(calibration.response.json.error?.data?.details?.reason, 'fold-pose-unsettled'); + const settleAttempts = calibration.hingeReadCount; + await withColdFoldHelperCache(async () => { + const trajectory = [ + { atMs: 0, angle: 0 }, + { atMs: MAX_FOLD_DURATION_MS, angle: 100 }, + ]; + let tracking = false; + let activeTool = false; + let firstCall!: () => void; + const started = new Promise((resolve) => { + firstCall = resolve; + }); + const { provider, ledger, hingeReadCount } = createFoldLedgerAppleToolProvider({ + hingeAngleAt: (index) => (index >= settleAttempts - 1 ? 100 : alternatingHingeAngle(index)), + isTracking: () => tracking, + onCall: async (call, signal) => { + if (!tracking) return; + firstCall(); + activeTool = true; + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(done, call.timeoutMs - 1 + (call.graceMs ?? 0)); + function done() { + signal?.removeEventListener('abort', aborted); + resolve(); + } + function aborted() { + clearTimeout(timer); + reject(signal!.reason); + } + signal?.addEventListener('abort', aborted, { once: true }); + if (signal?.aborted) aborted(); + }); + } finally { + activeTool = false; + } + }, + }); + const daemon = await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + appleRunnerProvider: () => ({ hasLiveSession: () => true, runCommand: async () => ({}) }), + appleToolProvider: () => provider, + }); + try { + await daemon.client().apps.open({ + app: 'com.example.demo', + platform: 'ios', + udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + }); + ledger.splice(0); + const scriptPath = path.join(isolatedHome, 'fold.ad'); + const request: DaemonWireRequest = { + token: daemon.token, + session: 'default', + command, + positionals: command === 'replay' ? [scriptPath] : [], + flags: { + platform: 'ios', + udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + ...(command === 'batch' + ? { + batchSteps: [ + { + command: 'fold', + positionals: [], + flags: { keyframes: JSON.stringify(trajectory) }, + }, + ], + } + : { + replayScriptSource: { + entry: scriptPath, + files: { [scriptPath]: `fold --keyframes '${JSON.stringify(trajectory)}'\n` }, + }, + }), + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }, + meta: { requestId: 'composite-fold-deadline' }, + }; + const envelopeMs = resolveCommandRequestTimeoutMs( + resolveCommandTimeoutPolicy(command), + request, + )!; + const operationBudgetMs = timeoutMs ?? 60_000; + assert.equal(envelopeMs, operationBudgetMs + 30_000); + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + const start = Date.now(); + tracking = true; + // Exercise the real client envelope and HTTP transport, including timeout classification. + const pending: Promise = sendRequest( + { baseUrl: daemon.baseUrl, token: daemon.token, pid: 1 }, + request, + 'http', + resolveDaemonPaths(daemon.stateDir), + envelopeMs, + { host: createTestHostEnvironment() }, + ).catch((error) => ({ ok: false, error: normalizeError(error) })); + let settled = false; + void pending.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await started; + for (let turn = 0; turn < 1000 && !settled; turn++) { + if (activeTool) await vi.advanceTimersToNextTimerAsync(); + await realSleep(1); + } + assert.ok(settled, `composite did not settle; ledger: ${JSON.stringify(ledger)}`); + const response = await pending; + const elapsedMs = Date.now() - start; + assert.ok( + elapsedMs + 30_000 <= envelopeMs, + `daemon result lost its margin: ${elapsedMs}; ledger: ${JSON.stringify(ledger)}`, + ); + if (timeoutMs === 300_000) { + assert.equal(response.ok, true, JSON.stringify(response)); + assert.equal(hingeReadCount(), settleAttempts); + assertFoldLedgerPhaseCoverage(ledger); + } else { + assert.equal(response.ok, false, JSON.stringify(response)); + if (!response.ok) { + assert.equal( + response.error.code, + command === 'batch' ? 'COMMAND_FAILED' : 'REPLAY_DIVERGENCE', + ); + assert.equal(response.error.details?.reason, 'daemon_request_timeout'); + assert.equal(response.error.details?.timeoutMs, operationBudgetMs); + if (command === 'replay') { + expect(response.error.details).toMatchObject({ + divergence: { step: { source: { path: scriptPath, line: 1 } } }, + }); + } + } + } + tracking = false; + vi.useRealTimers(); + assert.equal(await daemon.hasSession(), true); + assertRpcOk( + await daemon.callCommand('fold', [], { + platform: 'ios', + udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + keyframes: JSON.stringify(trajectory), + }), + ); + } finally { + tracking = false; + vi.useRealTimers(); + await daemon.close(); + } + }); + }, + 20_000, +); diff --git a/website/docs/docs/batching.md b/website/docs/docs/batching.md index 99fd7bcaac..205472708a 100644 --- a/website/docs/docs/batching.md +++ b/website/docs/docs/batching.md @@ -7,6 +7,13 @@ title: Batching Use `batch` to run several commands in one daemon request. It saves round trips when you already know the next sequence of actions. +The whole batch has a 60-second wall-clock budget. If an in-progress step settles after cancellation, +the batch returns `COMMAND_FAILED` with `details.reason: daemon_request_timeout`. The daemon and +its sessions stay available. A failed batch keeps `details.executed`, `details.partialResults`, +and its dispatch status, so you can continue from the failing step. A batch that completes every +step returns success even if it settles just after the deadline. A step that ignores cancellation +can delay the result past the client timeout. Split longer sequences into separate requests. + ## Run a batch from the CLI From a file: diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index c7d3e8f76d..c40a33f3c3 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -6,6 +6,14 @@ title: Replay & E2E Testing Record a session as an `.ad` script, then run it again with `replay` or run a folder of scripts as an E2E suite with `test`. +`replay` has a 60-second wall-clock budget. Use `replay workflow.ad --timeout 300000` to allow +five minutes. The client allows another 30 seconds for the result. If the operation budget +expires and an in-progress step settles after cancellation, a failed replay includes +`details.reason: daemon_request_timeout` and `details.timeoutMs`. The daemon and its sessions stay +available. A failed replay keeps the failing script path and line. A replay that completes every +step returns success even if it settles just after the deadline. A step that ignores cancellation +can delay the result past the client timeout. + To run Maestro YAML flows or export `.ad` scripts to Maestro YAML, see [Run Maestro flows](/docs/run-maestro-flows). ## How it works From 59947a354d8d1b4caa1df61dc36955d0cc7833fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 13:55:35 +0200 Subject: [PATCH 02/12] fix: keep replay failure diagnostics within canceled request --- packages/command-registry/src/registry.ts | 10 +- .../command-registry/src/timeout-policy.ts | 14 ++ .../touch-helper-cancellation.test.ts | 102 +++++++++ .../touch-helper-session.fixtures.ts | 129 ++++++++++++ .../__tests__/touch-helper-session.test.ts | 194 +----------------- .../src/daemon-port/replay-backend-failure.ts | 1 + .../daemon-port/session-replay-divergence.ts | 16 +- src/commands/batch/index.ts | 2 +- src/commands/replay/index.ts | 2 +- .../composite-deadline.test.ts | 8 + website/docs/docs/batching.md | 4 +- website/docs/docs/replay-e2e.md | 4 +- 12 files changed, 283 insertions(+), 203 deletions(-) create mode 100644 packages/platform-android/src/__tests__/touch-helper-cancellation.test.ts create mode 100644 packages/platform-android/src/__tests__/touch-helper-session.fixtures.ts diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 8cb1599a86..a0bc9a8dc7 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -14,6 +14,7 @@ import { } from '@agent-device/selectors'; import { resolveWaitBudgetMs } from './wait-positionals.ts'; import { + COMPOSITE_TIMEOUT_POLICIES, DEFAULT_TIMEOUT_POLICY, FOLD_TIMEOUT_POLICY, INSTALL_REQUEST_TIMEOUT_MS, @@ -859,12 +860,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ skipSessionlessProviderDevice: isShardedTestRequest, saveScriptFlagOwner: true, }, - // The daemon owns the operation budget; the client leaves time for its typed result. - timeoutPolicy: { - ...DEFAULT_TIMEOUT_POLICY, - daemonDeadlineMs: 60_000, - budget: { source: 'flag', envelope: 'margin' }, - }, + timeoutPolicy: COMPOSITE_TIMEOUT_POLICIES.replay, batchable: false, // Every native and Maestro action re-enters the daemon request pipeline under the delegated // command descriptor. Maestro viewport reads do the same through `runtime gesture-viewport`, @@ -1085,7 +1081,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: false, daemon: { route: 'session', refFrameEffect: 'delegated', appLease: 'allowed' }, - timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, daemonDeadlineMs: 60_000 }, + timeoutPolicy: COMPOSITE_TIMEOUT_POLICIES.batch, batchable: false, // Wave 6 residue: every step runs as its own daemon request under its own descriptor, which // is what `refFrameEffect: 'delegated'` already says. `batch` itself reaches no device. diff --git a/packages/command-registry/src/timeout-policy.ts b/packages/command-registry/src/timeout-policy.ts index 53f0f77080..aabf16fddd 100644 --- a/packages/command-registry/src/timeout-policy.ts +++ b/packages/command-registry/src/timeout-policy.ts @@ -47,6 +47,20 @@ export const DEFAULT_TIMEOUT_POLICY: CommandTimeoutPolicy = { onTimeout: 'reset-daemon', }; +/** Composite work gets 60 seconds; its client envelope keeps 30 seconds for the result. */ +const COMPOSITE_TIMEOUT_POLICY: CommandTimeoutPolicy = { + ...DEFAULT_TIMEOUT_POLICY, + daemonDeadlineMs: 60_000, +}; + +export const COMPOSITE_TIMEOUT_POLICIES = { + batch: COMPOSITE_TIMEOUT_POLICY, + replay: { + ...COMPOSITE_TIMEOUT_POLICY, + budget: { source: 'flag', envelope: 'margin' }, + }, +} satisfies Record<'batch' | 'replay', CommandTimeoutPolicy>; + /** * Covers the fold route's worst-case ledger (display inventory, fold-helper preparation, HID * dispatch, hinge settle reads, final display inventory) plus the daemon-result margin. Proven by diff --git a/packages/platform-android/src/__tests__/touch-helper-cancellation.test.ts b/packages/platform-android/src/__tests__/touch-helper-cancellation.test.ts new file mode 100644 index 0000000000..5ff918a0ef --- /dev/null +++ b/packages/platform-android/src/__tests__/touch-helper-cancellation.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import './test-utils/android-host-test-setup.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import { withAndroidAdbProvider } from '../adb-executor.ts'; +import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; +import { lowerAndroidTouchPlan } from '../touch-plan-lowering.ts'; +import { + executeAndroidTouchHelperPlan, + readAndroidTouchHelperViewportReading, +} from '../touch-helper.ts'; +import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from './test-utils/android-snapshot-helper.ts'; +import { + ANDROID_TOUCH_HELPER_MANIFEST as manifest, + currentVersionAdb, + flingPlan, + makeIsolatedDevice, +} from './touch-helper.fixtures.ts'; +import { + createFakeTouchHelperSessionProvider, + sessionHeaderResponse, +} from './touch-helper-session.fixtures.ts'; + +vi.mock('../helper-package-install.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveAndroidHelperArtifact: vi.fn(async () => ({ + apkPath: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT.apkPath, + manifest: { + ...manifest, + sha256: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT.manifest.sha256, + }, + })), + }; +}); + +beforeEach(async () => { + delete process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION; + await resetAndroidSnapshotHelperSessions(); +}); + +afterEach(async () => { + delete process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION; + await resetAndroidSnapshotHelperSessions(); +}); + +test('aborting an in-flight persistent gesture retires its helper without one-shot replay', async () => { + const device = makeIsolatedDevice(); + const controller = new AbortController(); + let entered!: () => void; + const injecting = new Promise((resolve) => { + entered = resolve; + }); + const provider = createFakeTouchHelperSessionProvider((command, requestId) => { + if (command.startsWith('gesture')) { + entered(); + return undefined; + } + return sessionHeaderResponse({ + agentDeviceProtocol: 'android-snapshot-helper-v1', + requestId, + ok: 'true', + x: '0', + y: '0', + width: '400', + height: '800', + }); + }); + let retired = false; + let oneShot = false; + await withAndroidAdbProvider( + { + ...provider, + exec: currentVersionAdb(async (args, options) => { + if (args.includes('force-stop')) retired = true; + if (args.includes('instrument')) oneShot = true; + return await provider.exec(args, options); + }), + }, + { serial: device.id }, + async () => { + await readAndroidTouchHelperViewportReading(device, { helperSessionScope: 'daemon-session' }); + const pending = executeAndroidTouchHelperPlan( + device, + lowerAndroidTouchPlan(flingPlan()), + controller.signal, + ); + await injecting; + const reason = new AppError('COMMAND_FAILED', 'deadline', { + reason: 'daemon_request_timeout', + }); + controller.abort(reason); + await assert.rejects( + pending, + (error) => error instanceof AppError && error.details?.reason === 'daemon_request_timeout', + ); + assert.equal(retired, true); + assert.equal(oneShot, false); + }, + ); +}); diff --git a/packages/platform-android/src/__tests__/touch-helper-session.fixtures.ts b/packages/platform-android/src/__tests__/touch-helper-session.fixtures.ts new file mode 100644 index 0000000000..86bc86cbd7 --- /dev/null +++ b/packages/platform-android/src/__tests__/touch-helper-session.fixtures.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import net from 'node:net'; +import { PassThrough } from 'node:stream'; +import type { AndroidAdbProcess, AndroidAdbProvider } from '../adb-executor.ts'; +import { + androidHelperRuntimeProbeResult, + isAndroidHelperRuntimeProbe, + type FakeAndroidHelperRuntimeRelease, +} from './snapshot-helper-session.fixtures.ts'; + +class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess { + stdin = new PassThrough(); + stdout = new PassThrough(); + stderr = new PassThrough(); + killed = false; + onKill: (() => void) | undefined; + + kill(): boolean { + this.killed = true; + this.onKill?.(); + return true; + } + + emitExit(code: number | null, signal: NodeJS.Signals | null): void { + this.emit('exit', code, signal); + this.emit('close', code, signal); + } +} + +type TouchSessionCommandHandler = (command: string, requestId: string) => string | undefined; + +function readSessionPort(args: readonly string[]): number { + const index = args.indexOf('sessionPort'); + assert.notEqual(index, -1); + return Number(args[index + 1]); +} + +export function sessionHeaderResponse(headers: Record): string { + return `${Object.entries(headers) + .map(([key, value]) => `${key}=${value}`) + .join('\n')}\n\n`; +} + +export function snapshotSessionResponse(requestId: string): string { + const body = ''; + const headers = { + agentDeviceProtocol: 'android-snapshot-helper-v1', + helperApiVersion: '1', + outputFormat: 'uiautomator-xml', + requestId, + ok: 'true', + byteLength: String(Buffer.byteLength(body, 'utf8')), + }; + return `${Object.entries(headers) + .map(([key, value]) => `${key}=${value}`) + .join('\n')}\n\n${body}`; +} + +function isTouchCleanupCommand(args: readonly string[]): boolean { + return ( + (args[0] === 'forward' && args[1] === '--remove') || + (args[0] === 'shell' && args[1] === 'am' && args[2] === 'force-stop') + ); +} + +function neverResolvingAfterAbort(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); +} + +export function createFakeTouchHelperSessionProvider( + handleCommand: TouchSessionCommandHandler, + options: { stallCleanup?: boolean; runtimeRelease?: FakeAndroidHelperRuntimeRelease } = {}, +): AndroidAdbProvider { + return { + exec: async (args, execOptions) => { + const signal = execOptions?.signal; + if (options.stallCleanup && signal && isTouchCleanupCommand(args)) { + return await neverResolvingAfterAbort(signal); + } + if (isAndroidHelperRuntimeProbe(args)) { + return androidHelperRuntimeProbeResult(options.runtimeRelease); + } + return { exitCode: 0, stdout: '', stderr: '' }; + }, + spawn: (args) => { + const port = readSessionPort(args); + const process = new FakeAndroidProcess(); + const server = net.createServer((socket) => { + socket.once('data', (chunk) => { + const command = chunk.toString('utf8').trim(); + const [, requestId = ''] = command.split(/\s+/, 2); + if (command.startsWith('quit')) { + socket.end( + sessionHeaderResponse({ + agentDeviceProtocol: 'android-snapshot-helper-v1', + requestId, + ok: 'true', + }), + ); + server.close(() => process.emitExit(0, null)); + return; + } + const response = handleCommand(command, requestId); + if (response !== undefined) socket.end(response); + }); + }); + server.listen(port, '127.0.0.1', () => { + process.stdout.write( + [ + 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_STATUS: sessionCommands=input,screenshot,focus,snapshot-immediate', + 'INSTRUMENTATION_STATUS: sessionReady=true', + 'INSTRUMENTATION_STATUS_CODE: 2', + '', + ].join('\n'), + ); + }); + process.onKill = () => { + server.close(() => process.emitExit(0, null)); + }; + return process; + }, + }; +} diff --git a/packages/platform-android/src/__tests__/touch-helper-session.test.ts b/packages/platform-android/src/__tests__/touch-helper-session.test.ts index 854b1f8de7..acbbddabd7 100644 --- a/packages/platform-android/src/__tests__/touch-helper-session.test.ts +++ b/packages/platform-android/src/__tests__/touch-helper-session.test.ts @@ -7,9 +7,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { EventEmitter } from 'node:events'; -import net from 'node:net'; -import { PassThrough } from 'node:stream'; import { afterEach, beforeEach, test, vi } from 'vitest'; import './test-utils/android-host-test-setup.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -19,16 +16,7 @@ import { DISPATCH_DISCLOSURE_TABLE_PATH, dispatchDisclosureRowsOwnedBy, } from '@agent-device/test-kit'; -import { - withAndroidAdbProvider, - type AndroidAdbProcess, - type AndroidAdbProvider, -} from '../adb-executor.ts'; -import { - androidHelperRuntimeProbeResult, - isAndroidHelperRuntimeProbe, - type FakeAndroidHelperRuntimeRelease, -} from './snapshot-helper-session.fixtures.ts'; +import { withAndroidAdbProvider } from '../adb-executor.ts'; import { captureAndroidSnapshotWithHelperSession } from '../snapshot-helper-session.ts'; import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; import { @@ -49,6 +37,11 @@ import { makeIsolatedDevice, outdatedVersionAdb, } from './touch-helper.fixtures.ts'; +import { + createFakeTouchHelperSessionProvider, + sessionHeaderResponse, + snapshotSessionResponse, +} from './touch-helper-session.fixtures.ts'; vi.mock('../helper-package-install.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -74,181 +67,6 @@ afterEach(async () => { await resetAndroidSnapshotHelperSessions(); }); -class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess { - stdin = new PassThrough(); - stdout = new PassThrough(); - stderr = new PassThrough(); - killed = false; - onKill: (() => void) | undefined; - - kill(): boolean { - this.killed = true; - this.onKill?.(); - return true; - } - - emitExit(code: number | null, signal: NodeJS.Signals | null): void { - this.emit('exit', code, signal); - this.emit('close', code, signal); - } -} - -type TouchSessionCommandHandler = (command: string, requestId: string) => string | undefined; - -function readSessionPort(args: readonly string[]): number { - const index = args.indexOf('sessionPort'); - assert.notEqual(index, -1); - return Number(args[index + 1]); -} - -function sessionHeaderResponse(headers: Record): string { - return `${Object.entries(headers) - .map(([key, value]) => `${key}=${value}`) - .join('\n')}\n\n`; -} - -function snapshotSessionResponse(requestId: string): string { - const body = ''; - const headers = { - agentDeviceProtocol: 'android-snapshot-helper-v1', - helperApiVersion: '1', - outputFormat: 'uiautomator-xml', - requestId, - ok: 'true', - byteLength: String(Buffer.byteLength(body, 'utf8')), - }; - return `${Object.entries(headers) - .map(([key, value]) => `${key}=${value}`) - .join('\n')}\n\n${body}`; -} - -function isTouchCleanupCommand(args: readonly string[]): boolean { - return ( - (args[0] === 'forward' && args[1] === '--remove') || - (args[0] === 'shell' && args[1] === 'am' && args[2] === 'force-stop') - ); -} - -function neverResolvingAfterAbort(signal: AbortSignal): Promise { - return new Promise((_resolve, reject) => { - const onAbort = () => reject(signal.reason); - signal.addEventListener('abort', onAbort, { once: true }); - if (signal.aborted) onAbort(); - }); -} - -function createFakeTouchHelperSessionProvider( - handleCommand: TouchSessionCommandHandler, - options: { stallCleanup?: boolean; runtimeRelease?: FakeAndroidHelperRuntimeRelease } = {}, -): AndroidAdbProvider { - return { - exec: async (args, execOptions) => { - const signal = execOptions?.signal; - if (options.stallCleanup && signal && isTouchCleanupCommand(args)) { - return await neverResolvingAfterAbort(signal); - } - if (isAndroidHelperRuntimeProbe(args)) { - return androidHelperRuntimeProbeResult(options.runtimeRelease); - } - return { exitCode: 0, stdout: '', stderr: '' }; - }, - spawn: (args) => { - const port = readSessionPort(args); - const process = new FakeAndroidProcess(); - const server = net.createServer((socket) => { - socket.once('data', (chunk) => { - const command = chunk.toString('utf8').trim(); - const [, requestId = ''] = command.split(/\s+/, 2); - if (command.startsWith('quit')) { - socket.end( - sessionHeaderResponse({ - agentDeviceProtocol: 'android-snapshot-helper-v1', - requestId, - ok: 'true', - }), - ); - server.close(() => process.emitExit(0, null)); - return; - } - const response = handleCommand(command, requestId); - if (response !== undefined) socket.end(response); - }); - }); - server.listen(port, '127.0.0.1', () => { - process.stdout.write( - [ - 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1', - 'INSTRUMENTATION_STATUS: sessionCommands=input,screenshot,focus,snapshot-immediate', - 'INSTRUMENTATION_STATUS: sessionReady=true', - 'INSTRUMENTATION_STATUS_CODE: 2', - '', - ].join('\n'), - ); - }); - process.onKill = () => { - server.close(() => process.emitExit(0, null)); - }; - return process; - }, - }; -} - -test('aborting an in-flight persistent gesture retires its helper without one-shot replay', async () => { - const device = makeIsolatedDevice(); - const controller = new AbortController(); - let entered!: () => void; - const injecting = new Promise((resolve) => { - entered = resolve; - }); - const provider = createFakeTouchHelperSessionProvider((command, requestId) => { - if (command.startsWith('gesture')) { - entered(); - return undefined; - } - return sessionHeaderResponse({ - agentDeviceProtocol: 'android-snapshot-helper-v1', - requestId, - ok: 'true', - x: '0', - y: '0', - width: '400', - height: '800', - }); - }); - let retired = false; - let oneShot = false; - await withAndroidAdbProvider( - { - ...provider, - exec: currentVersionAdb(async (args, options) => { - if (args.includes('force-stop')) retired = true; - if (args.includes('instrument')) oneShot = true; - return await provider.exec(args, options); - }), - }, - { serial: device.id }, - async () => { - await readAndroidTouchHelperViewportReading(device, { helperSessionScope: 'daemon-session' }); - const pending = executeAndroidTouchHelperPlan( - device, - lowerAndroidTouchPlan(flingPlan()), - controller.signal, - ); - await injecting; - const reason = new AppError('COMMAND_FAILED', 'deadline', { - reason: 'daemon_request_timeout', - }); - controller.abort(reason); - await assert.rejects( - pending, - (error) => error instanceof AppError && error.details?.reason === 'daemon_request_timeout', - ); - assert.equal(retired, true); - assert.equal(oneShot, false); - }, - ); -}); - test('touch helper does not run one-shot while the device still runs the helper', async () => { const device = makeIsolatedDevice(); const deviceKey = getAndroidSnapshotHelperSessionDeviceKey(device); diff --git a/packages/replay-port/src/daemon-port/replay-backend-failure.ts b/packages/replay-port/src/daemon-port/replay-backend-failure.ts index d03bd2710d..d632c3f12c 100644 --- a/packages/replay-port/src/daemon-port/replay-backend-failure.ts +++ b/packages/replay-port/src/daemon-port/replay-backend-failure.ts @@ -46,6 +46,7 @@ export async function buildReplayBackendActionFailureResponse( observationStore, logPath, action: captureAction, + signal: requestSignal, }) : { state: 'unavailable' as const, diff --git a/packages/replay-port/src/daemon-port/session-replay-divergence.ts b/packages/replay-port/src/daemon-port/session-replay-divergence.ts index ef1f317e33..6557edc9a9 100644 --- a/packages/replay-port/src/daemon-port/session-replay-divergence.ts +++ b/packages/replay-port/src/daemon-port/session-replay-divergence.ts @@ -107,6 +107,7 @@ export async function buildReplayFailureDivergence(params: { observationStore, logPath, action, + signal, }) : ({ state: 'unavailable', @@ -254,14 +255,16 @@ export async function captureDivergenceObservation(params: { logPath: string; action: ReplayReportAction; retryLaunchRace?: boolean; + signal?: AbortSignal; }): Promise { - const { session, observationStore, logPath, action, retryLaunchRace = false } = params; + const { session, observationStore, logPath, action, retryLaunchRace = false, signal } = params; + if (signal?.aborted) return canceledDivergenceObservation(); const flags = divergenceCaptureFlags(action); // Anchored BEFORE the first attempt, not after: a slow first capture // shrinks the retry budget rather than getting a free `DEADLINE_MS` on top // of however long it took. const deadline = Date.now() + DIVERGENCE_CAPTURE_RETRY_DEADLINE_MS; - const observation = observationStore.bindAuthority(); + const observation = observationStore.bindAuthority(signal); let attempt = await captureDivergenceObservationAttempt({ session, @@ -276,6 +279,7 @@ export async function captureDivergenceObservation(params: { const remainingMs = deadline - Date.now(); if (remainingMs <= 0) break; await sleep(Math.min(delayMs, remainingMs)); + if (signal?.aborted) return canceledDivergenceObservation(); attempt = await captureDivergenceObservationAttempt({ session, observation, @@ -287,6 +291,14 @@ export async function captureDivergenceObservation(params: { return attempt.observation; } +function canceledDivergenceObservation(): DivergenceObservation { + return { + state: 'unavailable', + reason: 'request-canceled', + hint: 'Post-failure snapshot skipped after request cancellation; the original replay failure is unaffected.', + }; +} + type DivergenceCaptureAttempt = { observation: DivergenceObservation; /** diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index ce89c3343c..41e84f78f4 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -53,7 +53,7 @@ function buildBatchCliDetail(): string { const prose = [ 'Each step is {"command":"","input":{...}}, and input is that command\'s structured input object rather than its terminal spelling: a CLI positional becomes a named field (target, text, direction) and a CLI flag becomes a camelCase key (--settle -> "settle":true, -i -> "interactiveOnly":true). There is no positional step form; args, argv, positionals, and flags are not step fields. agent-device help lists arguments in CLI spelling only. The examples below carry the structured spelling; over MCP, the command tool schema states it in full.', 'Steps run serially in one daemon request against the same session, in order. Mutating UI verbs are included (press, click, fill, longpress, scroll, back), which is where the round-trip saving is; --on-error stop halts at the first failing step.', - 'The whole batch has a 60-second wall-clock budget. A step that settles after cancellation returns COMMAND_FAILED with details.reason daemon_request_timeout; the daemon and its sessions remain available. Failure preserves executed, partialResults, and dispatch status. A completed batch returns success even if it settles just after the deadline. A step that ignores cancellation can delay the result past the client timeout. Split longer sequences into separate requests.', + 'The whole batch has a 60-second wall-clock budget. A step that settles after cancellation returns COMMAND_FAILED with details.reason daemon_request_timeout; the daemon remains available. A close step may already have ended its session. Failure preserves executed, partialResults, and dispatch status. A completed batch returns success even if it settles just after the deadline. A step that ignores cancellation can delay the result past the client timeout. Split longer sequences into separate requests.', `Available through batch: ${[...STRUCTURED_BATCH_COMMAND_NAMES].sort().join(', ')}.`, 'Every other command is excluded: batch and replay never nest, and session, daemon, connection, and host tooling commands own lifecycle a batch request cannot carry. Run those on their own.', ].join(' '); diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index ba55b60ea4..c6f8a103bb 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -299,7 +299,7 @@ export const replayCommandFacet = defineCommandFacet({ text: { summary: 'Replay a recorded session or Maestro flow', cliDetail: - 'Replay has a 60-second wall-clock budget; --timeout changes it. A step that settles after cancellation returns a typed failure with details.reason daemon_request_timeout and details.timeoutMs; the daemon and its sessions remain available. Failure preserves the failing script path and line. A completed replay returns success even if it settles just after the deadline. The client allows another 30 seconds for the result. A step that ignores cancellation can delay the result past the client timeout. For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively. For native .ad scripts, --keep-session suppresses exactly an authored terminal close so you can continue interactively. replay export converts compatible actions to Maestro YAML locally, including app switches with explicit launchApp.appId targets, deep links (including tel: and mailto:) as openLink, and home as pressKey: Home.', + 'Replay has a 60-second wall-clock budget; --timeout changes it. A step that settles after cancellation returns a typed failure with details.reason daemon_request_timeout and details.timeoutMs; the daemon remains available. An authored close may already have ended its session. Failure preserves the failing script path and line. A completed replay returns success even if it settles just after the deadline. The client allows another 30 seconds for the result. A step that ignores cancellation can delay the result past the client timeout. For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively. For native .ad scripts, --keep-session suppresses exactly an authored terminal close so you can continue interactively. replay export converts compatible actions to Maestro YAML locally, including app switches with explicit launchApp.appId targets, deep links (including tel: and mailto:) as openLink, and home as pressKey: Home.', }, metadata: replayCommandMetadata, run: (client, input) => client.replay.run(withCommandRuntimeHints(input)), diff --git a/test/integration/provider-scenarios/composite-deadline.test.ts b/test/integration/provider-scenarios/composite-deadline.test.ts index b377be2720..e93aa29bbb 100644 --- a/test/integration/provider-scenarios/composite-deadline.test.ts +++ b/test/integration/provider-scenarios/composite-deadline.test.ts @@ -20,6 +20,8 @@ test.each(['batch', 'replay'])( }); let signal: AbortSignal | undefined; let retired = false; + let diagnosticCaptureAfterAbort = false; + let pendingDeadlineResult = true; const completedActions: string[] = []; let searchText = ''; const daemon = await createProviderScenarioHarness({ @@ -46,6 +48,10 @@ test.each(['batch', 'replay'])( signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }); }); } + if (pendingDeadlineResult && signal?.aborted && args.includes('instrument')) { + diagnosticCaptureAfterAbort = true; + throw new Error('post-failure snapshot transport is stalled'); + } if (args.includes('force-stop')) { if (signal?.aborted) retired = true; return { exitCode: 0, stdout: '', stderr: '' }; @@ -106,6 +112,7 @@ test.each(['batch', 'replay'])( assert.deepEqual(completedActions, ['press', 'fill']); await vi.advanceTimersByTimeAsync(timeoutMs); const response = await pending; + pendingDeadlineResult = false; assert.equal( response.json.error?.data?.details?.reason, 'daemon_request_timeout', @@ -130,6 +137,7 @@ test.each(['batch', 'replay'])( }); } assert.equal(signal?.aborted, true); + assert.equal(diagnosticCaptureAfterAbort, false); assert.equal( retired, true, diff --git a/website/docs/docs/batching.md b/website/docs/docs/batching.md index 205472708a..9327fea75a 100644 --- a/website/docs/docs/batching.md +++ b/website/docs/docs/batching.md @@ -8,8 +8,8 @@ Use `batch` to run several commands in one daemon request. It saves round trips the next sequence of actions. The whole batch has a 60-second wall-clock budget. If an in-progress step settles after cancellation, -the batch returns `COMMAND_FAILED` with `details.reason: daemon_request_timeout`. The daemon and -its sessions stay available. A failed batch keeps `details.executed`, `details.partialResults`, +the batch returns `COMMAND_FAILED` with `details.reason: daemon_request_timeout`. The daemon stays +available. A `close` step may already have ended its session. A failed batch keeps `details.executed`, `details.partialResults`, and its dispatch status, so you can continue from the failing step. A batch that completes every step returns success even if it settles just after the deadline. A step that ignores cancellation can delay the result past the client timeout. Split longer sequences into separate requests. diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index c40a33f3c3..68df3245c7 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -9,8 +9,8 @@ Record a session as an `.ad` script, then run it again with `replay` or run a fo `replay` has a 60-second wall-clock budget. Use `replay workflow.ad --timeout 300000` to allow five minutes. The client allows another 30 seconds for the result. If the operation budget expires and an in-progress step settles after cancellation, a failed replay includes -`details.reason: daemon_request_timeout` and `details.timeoutMs`. The daemon and its sessions stay -available. A failed replay keeps the failing script path and line. A replay that completes every +`details.reason: daemon_request_timeout` and `details.timeoutMs`. The daemon stays available. An +authored `close` may already have ended its session. A failed replay keeps the failing script path and line. A replay that completes every step returns success even if it settles just after the deadline. A step that ignores cancellation can delay the result past the client timeout. From df842906bacd50fbd08264d0900af7c0718e1c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 13:56:12 +0200 Subject: [PATCH 03/12] test: keep extracted touch helper fixture typing --- .../src/__tests__/touch-helper-session.fixtures.ts | 2 +- .../platform-android/src/__tests__/touch-helper-session.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/platform-android/src/__tests__/touch-helper-session.fixtures.ts b/packages/platform-android/src/__tests__/touch-helper-session.fixtures.ts index 86bc86cbd7..d515cc220c 100644 --- a/packages/platform-android/src/__tests__/touch-helper-session.fixtures.ts +++ b/packages/platform-android/src/__tests__/touch-helper-session.fixtures.ts @@ -28,7 +28,7 @@ class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess { } } -type TouchSessionCommandHandler = (command: string, requestId: string) => string | undefined; +export type TouchSessionCommandHandler = (command: string, requestId: string) => string | undefined; function readSessionPort(args: readonly string[]): number { const index = args.indexOf('sessionPort'); diff --git a/packages/platform-android/src/__tests__/touch-helper-session.test.ts b/packages/platform-android/src/__tests__/touch-helper-session.test.ts index acbbddabd7..c9da0919b4 100644 --- a/packages/platform-android/src/__tests__/touch-helper-session.test.ts +++ b/packages/platform-android/src/__tests__/touch-helper-session.test.ts @@ -41,6 +41,7 @@ import { createFakeTouchHelperSessionProvider, sessionHeaderResponse, snapshotSessionResponse, + type TouchSessionCommandHandler, } from './touch-helper-session.fixtures.ts'; vi.mock('../helper-package-install.ts', async (importOriginal) => { From 3832efa2188934249d79a3259cca4d581db57a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 13:59:13 +0200 Subject: [PATCH 04/12] test: keep deadline provider fixtures focused --- .../composite-deadline.test.ts | 127 +++++++++++------- .../provider-scenarios/ios-fold.test.ts | 68 +++++++--- 2 files changed, 123 insertions(+), 72 deletions(-) diff --git a/test/integration/provider-scenarios/composite-deadline.test.ts b/test/integration/provider-scenarios/composite-deadline.test.ts index e93aa29bbb..b0cd769e3e 100644 --- a/test/integration/provider-scenarios/composite-deadline.test.ts +++ b/test/integration/provider-scenarios/composite-deadline.test.ts @@ -1,12 +1,72 @@ import assert from 'node:assert/strict'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; +import type { AndroidAdbProvider } from '@agent-device/platform-android/mechanics'; import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../../src/__tests__/test-utils/android-snapshot-helper.ts'; import { createProviderScenarioHarness } from './harness.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; import { respondToAndroidSettingsAdbCommand } from './android-world.ts'; import { assertRpcOk } from './assertions.ts'; +type DeadlineAdbState = { + started: () => void; + observedFill: () => void; + signal?: AbortSignal; + retired: boolean; + diagnosticCaptureAfterAbort: boolean; + pendingDeadlineResult: boolean; + completedActions: string[]; + searchText: string; +}; + +function awaitGestureAbort(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); +} + +function deadlineAdbProvider(state: DeadlineAdbState): AndroidAdbProvider { + return { + snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + text: async (request) => { + state.searchText = request.text; + state.completedActions.push('fill'); + }, + exec: async (args, options) => { + if (args.join(' ').includes('AGENT_DEVICE_NO_HELPER')) + return { exitCode: 0, stdout: 'AGENT_DEVICE_NO_HELPER\n', stderr: '' }; + if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'tap') { + state.completedActions.push('press'); + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args.includes('instrument') && args.includes('gesture')) { + state.signal = options?.signal; + state.started(); + assert.ok(state.signal, 'longpress must carry the request signal to its helper'); + return await awaitGestureAbort(state.signal); + } + if (state.pendingDeadlineResult && state.signal?.aborted && args.includes('instrument')) { + state.diagnosticCaptureAfterAbort = true; + throw new Error('post-failure snapshot transport is stalled'); + } + if (args.includes('force-stop')) { + if (state.signal?.aborted) state.retired = true; + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args.includes('--show-versioncode')) + return { + exitCode: 0, + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:999999', + stderr: '', + }; + if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'start') + return { exitCode: 0, stdout: 'Starting: Intent', stderr: '' }; + if (state.searchText && args.includes('instrument')) state.observedFill(); + return respondToAndroidSettingsAdbCommand([...args], state.searchText, '', {}); + }, + }; +} + test.each(['batch', 'replay'])( '%s deadline cancels Android longpress instrumentation and preserves its session', async (command) => { @@ -18,56 +78,19 @@ test.each(['batch', 'replay'])( const fillObservation = new Promise((resolve) => { observedFill = resolve; }); - let signal: AbortSignal | undefined; - let retired = false; - let diagnosticCaptureAfterAbort = false; - let pendingDeadlineResult = true; - const completedActions: string[] = []; - let searchText = ''; + const state: DeadlineAdbState = { + started, + observedFill, + retired: false, + diagnosticCaptureAfterAbort: false, + pendingDeadlineResult: true, + completedActions: [], + searchText: '', + }; const daemon = await createProviderScenarioHarness({ deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], env: { AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION: '0' }, - androidAdbProvider: () => ({ - snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, - text: async (request) => { - searchText = request.text; - completedActions.push('fill'); - }, - exec: async (args, options) => { - if (args.join(' ').includes('AGENT_DEVICE_NO_HELPER')) - return { exitCode: 0, stdout: 'AGENT_DEVICE_NO_HELPER\n', stderr: '' }; - if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'tap') { - completedActions.push('press'); - return { exitCode: 0, stdout: '', stderr: '' }; - } - if (args.includes('instrument') && args.includes('gesture')) { - signal = options?.signal; - started(); - assert.ok(signal, 'longpress must carry the request signal to its helper'); - return await new Promise((_resolve, reject) => { - signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }); - }); - } - if (pendingDeadlineResult && signal?.aborted && args.includes('instrument')) { - diagnosticCaptureAfterAbort = true; - throw new Error('post-failure snapshot transport is stalled'); - } - if (args.includes('force-stop')) { - if (signal?.aborted) retired = true; - return { exitCode: 0, stdout: '', stderr: '' }; - } - if (args.includes('--show-versioncode')) - return { - exitCode: 0, - stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:999999', - stderr: '', - }; - if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'start') - return { exitCode: 0, stdout: 'Starting: Intent', stderr: '' }; - if (searchText && args.includes('instrument')) observedFill(); - return respondToAndroidSettingsAdbCommand([...args], searchText, '', {}); - }, - }), + androidAdbProvider: () => deadlineAdbProvider(state), }); try { await daemon.client().apps.open({ @@ -109,10 +132,10 @@ test.each(['batch', 'replay'])( injecting, pending.then((response) => assert.fail(JSON.stringify(response.json))), ]); - assert.deepEqual(completedActions, ['press', 'fill']); + assert.deepEqual(state.completedActions, ['press', 'fill']); await vi.advanceTimersByTimeAsync(timeoutMs); const response = await pending; - pendingDeadlineResult = false; + state.pendingDeadlineResult = false; assert.equal( response.json.error?.data?.details?.reason, 'daemon_request_timeout', @@ -136,10 +159,10 @@ test.each(['batch', 'replay'])( divergence: { step: { source: { path: script, line: 4 } } }, }); } - assert.equal(signal?.aborted, true); - assert.equal(diagnosticCaptureAfterAbort, false); + assert.equal(state.signal?.aborted, true); + assert.equal(state.diagnosticCaptureAfterAbort, false); assert.equal( - retired, + state.retired, true, 'canceled one-shot instrumentation must release its device runtime', ); diff --git a/test/integration/provider-scenarios/ios-fold.test.ts b/test/integration/provider-scenarios/ios-fold.test.ts index 893aa8c3db..49a97f7a6d 100644 --- a/test/integration/provider-scenarios/ios-fold.test.ts +++ b/test/integration/provider-scenarios/ios-fold.test.ts @@ -406,6 +406,44 @@ test('fold worst-case ledger covers the client envelope with the daemon-result m ); }, 20_000); +function assertCompositeFoldOutcome(params: { + response: DaemonResponse; + command: 'batch' | 'replay'; + timeoutMs?: number; + operationBudgetMs: number; + scriptPath: string; + hingeReadCount: () => number; + settleAttempts: number; + ledger: readonly FoldLedgerCall[]; +}): void { + const { + response, + command, + timeoutMs, + operationBudgetMs, + scriptPath, + hingeReadCount, + settleAttempts, + ledger, + } = params; + if (timeoutMs === 300_000) { + assert.equal(response.ok, true, JSON.stringify(response)); + assert.equal(hingeReadCount(), settleAttempts); + assertFoldLedgerPhaseCoverage(ledger); + return; + } + assert.equal(response.ok, false, JSON.stringify(response)); + if (response.ok) return; + assert.equal(response.error.code, command === 'batch' ? 'COMMAND_FAILED' : 'REPLAY_DIVERGENCE'); + assert.equal(response.error.details?.reason, 'daemon_request_timeout'); + assert.equal(response.error.details?.timeoutMs, operationBudgetMs); + if (command === 'replay') { + expect(response.error.details).toMatchObject({ + divergence: { step: { source: { path: scriptPath, line: 1 } } }, + }); + } +} + test.each([ { command: 'batch' }, { command: 'replay' }, @@ -539,26 +577,16 @@ test.each([ elapsedMs + 30_000 <= envelopeMs, `daemon result lost its margin: ${elapsedMs}; ledger: ${JSON.stringify(ledger)}`, ); - if (timeoutMs === 300_000) { - assert.equal(response.ok, true, JSON.stringify(response)); - assert.equal(hingeReadCount(), settleAttempts); - assertFoldLedgerPhaseCoverage(ledger); - } else { - assert.equal(response.ok, false, JSON.stringify(response)); - if (!response.ok) { - assert.equal( - response.error.code, - command === 'batch' ? 'COMMAND_FAILED' : 'REPLAY_DIVERGENCE', - ); - assert.equal(response.error.details?.reason, 'daemon_request_timeout'); - assert.equal(response.error.details?.timeoutMs, operationBudgetMs); - if (command === 'replay') { - expect(response.error.details).toMatchObject({ - divergence: { step: { source: { path: scriptPath, line: 1 } } }, - }); - } - } - } + assertCompositeFoldOutcome({ + response, + command, + timeoutMs, + operationBudgetMs, + scriptPath, + hingeReadCount, + settleAttempts, + ledger, + }); tracking = false; vi.useRealTimers(); assert.equal(await daemon.hasSession(), true); From 23df47e382c72a57f081503cfa764d412f6e6ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 14:00:12 +0200 Subject: [PATCH 05/12] test: separate deadline provider infrastructure replies --- .../composite-deadline.test.ts | 50 ++++++++++++------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/test/integration/provider-scenarios/composite-deadline.test.ts b/test/integration/provider-scenarios/composite-deadline.test.ts index b0cd769e3e..942f0609b6 100644 --- a/test/integration/provider-scenarios/composite-deadline.test.ts +++ b/test/integration/provider-scenarios/composite-deadline.test.ts @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; -import type { AndroidAdbProvider } from '@agent-device/platform-android/mechanics'; +import type { + AndroidAdbExecutorResult, + AndroidAdbProvider, +} from '@agent-device/platform-android/mechanics'; import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../../src/__tests__/test-utils/android-snapshot-helper.ts'; import { createProviderScenarioHarness } from './harness.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; @@ -25,6 +28,31 @@ function awaitGestureAbort(signal: AbortSignal): Promise { }); } +function deadlineAdbInfrastructureResponse( + args: readonly string[], + state: DeadlineAdbState, +): AndroidAdbExecutorResult | undefined { + if (args.join(' ').includes('AGENT_DEVICE_NO_HELPER')) + return { exitCode: 0, stdout: 'AGENT_DEVICE_NO_HELPER\n', stderr: '' }; + if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'tap') { + state.completedActions.push('press'); + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args.includes('force-stop')) { + if (state.signal?.aborted) state.retired = true; + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args.includes('--show-versioncode')) + return { + exitCode: 0, + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:999999', + stderr: '', + }; + if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'start') + return { exitCode: 0, stdout: 'Starting: Intent', stderr: '' }; + return undefined; +} + function deadlineAdbProvider(state: DeadlineAdbState): AndroidAdbProvider { return { snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, @@ -33,12 +61,8 @@ function deadlineAdbProvider(state: DeadlineAdbState): AndroidAdbProvider { state.completedActions.push('fill'); }, exec: async (args, options) => { - if (args.join(' ').includes('AGENT_DEVICE_NO_HELPER')) - return { exitCode: 0, stdout: 'AGENT_DEVICE_NO_HELPER\n', stderr: '' }; - if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'tap') { - state.completedActions.push('press'); - return { exitCode: 0, stdout: '', stderr: '' }; - } + const infrastructure = deadlineAdbInfrastructureResponse(args, state); + if (infrastructure) return infrastructure; if (args.includes('instrument') && args.includes('gesture')) { state.signal = options?.signal; state.started(); @@ -49,18 +73,6 @@ function deadlineAdbProvider(state: DeadlineAdbState): AndroidAdbProvider { state.diagnosticCaptureAfterAbort = true; throw new Error('post-failure snapshot transport is stalled'); } - if (args.includes('force-stop')) { - if (state.signal?.aborted) state.retired = true; - return { exitCode: 0, stdout: '', stderr: '' }; - } - if (args.includes('--show-versioncode')) - return { - exitCode: 0, - stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:999999', - stderr: '', - }; - if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'start') - return { exitCode: 0, stdout: 'Starting: Intent', stderr: '' }; if (state.searchText && args.includes('instrument')) state.observedFill(); return respondToAndroidSettingsAdbCommand([...args], state.searchText, '', {}); }, From 98c0e02749fb2240882ae5894b6b2a2f934c3008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 14:03:59 +0200 Subject: [PATCH 06/12] docs: describe deadline settlement without banned wording --- website/docs/docs/batching.md | 7 ++++--- website/docs/docs/replay-e2e.md | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/website/docs/docs/batching.md b/website/docs/docs/batching.md index 9327fea75a..6dbdd5250e 100644 --- a/website/docs/docs/batching.md +++ b/website/docs/docs/batching.md @@ -9,9 +9,10 @@ the next sequence of actions. The whole batch has a 60-second wall-clock budget. If an in-progress step settles after cancellation, the batch returns `COMMAND_FAILED` with `details.reason: daemon_request_timeout`. The daemon stays -available. A `close` step may already have ended its session. A failed batch keeps `details.executed`, `details.partialResults`, -and its dispatch status, so you can continue from the failing step. A batch that completes every -step returns success even if it settles just after the deadline. A step that ignores cancellation +available. A `close` step may already have ended its session. A failed batch keeps +`details.executed`, `details.partialResults`, and its dispatch status, so you can continue from +the failing step. A batch that completes every step returns success even if it settles shortly +after the deadline. A step that ignores cancellation can delay the result past the client timeout. Split longer sequences into separate requests. ## Run a batch from the CLI diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index 68df3245c7..113107379f 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -10,8 +10,9 @@ Record a session as an `.ad` script, then run it again with `replay` or run a fo five minutes. The client allows another 30 seconds for the result. If the operation budget expires and an in-progress step settles after cancellation, a failed replay includes `details.reason: daemon_request_timeout` and `details.timeoutMs`. The daemon stays available. An -authored `close` may already have ended its session. A failed replay keeps the failing script path and line. A replay that completes every -step returns success even if it settles just after the deadline. A step that ignores cancellation +authored `close` may already have ended its session. A failed replay keeps the failing script path +and line. A replay that completes every step returns success even if it settles shortly after the +deadline. A step that ignores cancellation can delay the result past the client timeout. To run Maestro YAML flows or export `.ad` scripts to Maestro YAML, see [Run Maestro flows](/docs/run-maestro-flows). From ea365d77065003e3b745b81c36aa100b4ba61f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 14:12:39 +0200 Subject: [PATCH 07/12] fix: cancel annotated replay observations with request --- .../session-replay-runtime-engine-adapter.ts | 1 + .../session-replay-target-verification.ts | 6 +- .../composite-deadline.test.ts | 77 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/replay-port/src/daemon-port/session-replay-runtime-engine-adapter.ts b/packages/replay-port/src/daemon-port/session-replay-runtime-engine-adapter.ts index 00870c63d4..586de3414a 100644 --- a/packages/replay-port/src/daemon-port/session-replay-runtime-engine-adapter.ts +++ b/packages/replay-port/src/daemon-port/session-replay-runtime-engine-adapter.ts @@ -181,6 +181,7 @@ export function createAdReplayStepRuntime(params: { logPath: ctx.logPath, action, retryLaunchRace: true, + signal: ctx.signal, }); }; diff --git a/packages/replay-port/src/daemon-port/session-replay-target-verification.ts b/packages/replay-port/src/daemon-port/session-replay-target-verification.ts index 26fe88a5f4..6cc42477d0 100644 --- a/packages/replay-port/src/daemon-port/session-replay-target-verification.ts +++ b/packages/replay-port/src/daemon-port/session-replay-target-verification.ts @@ -278,14 +278,16 @@ async function captureFreshObservation(params: { logPath: string; action: SessionAction; unavailableHint: string; + signal?: AbortSignal; }): Promise { - const { session, observationStore, logPath, action, unavailableHint } = params; + const { session, observationStore, logPath, action, unavailableHint, signal } = params; return session ? await captureDivergenceObservation({ session, observationStore, logPath, action, + signal, }) : { state: 'unavailable', reason: 'no-session', hint: unavailableHint }; } @@ -307,6 +309,7 @@ export async function buildRecordedUnverifiableFailureResponse( ): Promise { const observation = await captureFreshObservation({ ...params, + signal: context.signal, unavailableHint: 'The session closed before a screen could be captured to verify the recorded target evidence.', }); @@ -343,6 +346,7 @@ export async function buildPostDispatchTargetBindingFailureResponse( ): Promise { const observation = await captureFreshObservation({ ...params, + signal: context.signal, unavailableHint: 'The session closed before a post-failure screen could be captured.', }); return buildTargetBindingFailureResponse(context, evidence, observation); diff --git a/test/integration/provider-scenarios/composite-deadline.test.ts b/test/integration/provider-scenarios/composite-deadline.test.ts index 942f0609b6..2f026f6d7c 100644 --- a/test/integration/provider-scenarios/composite-deadline.test.ts +++ b/test/integration/provider-scenarios/composite-deadline.test.ts @@ -188,3 +188,80 @@ test.each(['batch', 'replay'])( }, 20_000, ); + +test('annotated replay capture aborts within its deadline without post-failure fallback', async () => { + let captureStarted!: () => void; + const started = new Promise((resolve) => { + captureStarted = resolve; + }); + let captureSignal: AbortSignal | undefined; + let captureAfterAbort = false; + let captureAttempts = 0; + let stallReplayCapture = false; + const daemon = await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + env: { AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION: '0' }, + androidAdbProvider: () => ({ + snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + exec: async (args, options) => { + if (stallReplayCapture && args.includes('instrument')) { + captureAttempts += 1; + if (captureSignal?.aborted) captureAfterAbort = true; + captureSignal = options?.signal; + captureStarted(); + if (!captureSignal) throw new Error('annotated replay capture lost the request signal'); + return await awaitGestureAbort(captureSignal); + } + if (args.includes('--show-versioncode')) + return { + exitCode: 0, + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:999999', + stderr: '', + }; + if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'start') + return { exitCode: 0, stdout: 'Starting: Intent', stderr: '' }; + return respondToAndroidSettingsAdbCommand([...args], '', '', {}); + }, + }), + }); + try { + await daemon.client().apps.open({ + app: 'com.android.settings', + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + stallReplayCapture = true; + const script = path.join(daemon.stateDir, 'annotated.ad'); + const source = [ + '# agent-device:target-v1 {"role":"button","ancestry":[],"sibling":0,"viewportOrder":0,"verification":"unverifiable"}', + 'click @e1', + '', + ].join('\n'); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + const pending = daemon.callCommand('replay', [script], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + timeoutMs: 1_000, + replayScriptSource: { entry: script, files: { [script]: source } }, + }); + await started; + assert.ok(captureSignal, 'target observation must carry the request signal'); + await vi.advanceTimersByTimeAsync(1_000); + const response = await pending; + assert.equal( + response.json.error?.data?.details?.reason, + 'daemon_request_timeout', + JSON.stringify(response.json), + ); + expect(response.json.error.data.details).toMatchObject({ + timeoutMs: 1_000, + divergence: { step: { source: { path: script, line: 2 } } }, + }); + assert.equal(captureSignal.aborted, true); + assert.equal(captureAttempts, 1); + assert.equal(captureAfterAbort, false); + } finally { + vi.useRealTimers(); + await daemon.close(); + } +}, 20_000); From 6b438f229db9e1f70bdc9226c97ca23b13afea43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 14:29:59 +0200 Subject: [PATCH 08/12] fix: treat daemon deadline as request cancellation internally --- .../src/internal/request-cancel.test.ts | 99 ++++++++++++++++++- .../host-kit/src/internal/request-cancel.ts | 48 +++++++-- .../runner/__tests__/runner-exchange.test.ts | 33 +++++++ src/commands/batch/index.ts | 2 +- src/commands/replay/index.ts | 2 +- .../composite-deadline.test.ts | 73 ++++++++++++++ website/docs/docs/batching.md | 14 ++- website/docs/docs/replay-e2e.md | 15 +-- 8 files changed, 262 insertions(+), 24 deletions(-) diff --git a/packages/host-kit/src/internal/request-cancel.test.ts b/packages/host-kit/src/internal/request-cancel.test.ts index 91904ac968..0cfd62193d 100644 --- a/packages/host-kit/src/internal/request-cancel.test.ts +++ b/packages/host-kit/src/internal/request-cancel.test.ts @@ -45,6 +45,7 @@ test('daemon deadline cancels in-flight work with its typed reason and retains r ); await vi.advanceTimersByTimeAsync(60_000); assert.equal(signal.aborted, true); + assert.equal(isRequestCanceledError(signal.reason), true); assert.equal(getRequestSignal('deadline'), signal); finish(); await rejected; @@ -142,6 +143,40 @@ test('deadline failure retains response diagnostics and progress while adding ti } }); +test('deadline projection replaces cancellation copy while keeping completed-step evidence', async () => { + vi.useFakeTimers(); + try { + const result = await withRequestDeadline('cancel-copy', 100, async () => { + await vi.advanceTimersByTimeAsync(100); + return { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'request canceled', + hint: 'The request was canceled intentionally', + details: { + reason: 'request_canceled', + executed: 1, + partialResults: [{ command: 'press' }], + source: { path: '/tmp/workflow.ad', line: 3 }, + }, + }, + } satisfies DaemonResponse; + }); + assert.equal(result.ok, false); + if (result.ok) assert.fail('deadline failure became a success'); + assert.equal(result.error.message, 'Daemon request deadline exceeded'); + assert.doesNotMatch(String(result.error.hint), /canceled intentionally/); + assert.deepEqual(result.error.details?.partialResults, [{ command: 'press' }]); + assert.deepEqual(result.error.details?.source, { path: '/tmp/workflow.ad', line: 3 }); + assert.equal(result.error.details?.executed, 1); + assert.equal(result.error.details?.causeReason, 'request_canceled'); + assert.equal(result.error.details?.reason, 'daemon_request_timeout'); + } finally { + vi.useRealTimers(); + } +}); + test('a thrown error before the deadline retains its identity', async () => { const error = new AppError('INVALID_ARGS', 'Invalid replay'); await assert.rejects( @@ -152,6 +187,64 @@ test('a thrown error before the deadline retains its identity', async () => { ); }); +test('a deadline after a thrown cancellation preserves its cause and progress', async () => { + vi.useFakeTimers(); + try { + await assert.rejects( + withRequestDeadline('thrown-progress', 100, async () => { + await vi.advanceTimersByTimeAsync(100); + throw new AppError('COMMAND_FAILED', 'request canceled', { + reason: 'request_canceled', + hint: 'The request was canceled intentionally', + executed: 2, + partialResults: [{ command: 'press' }, { command: 'fill' }], + dispatched: 'unknown', + source: { path: '/tmp/workflow.ad', line: 4 }, + diagnosticId: 'diag-4', + logPath: '/tmp/request.log', + }); + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'Daemon request deadline exceeded'); + assert.doesNotMatch(String(error.details?.hint), /canceled intentionally/); + assert.equal(error.details?.reason, 'daemon_request_timeout'); + assert.equal(error.details?.causeReason, 'request_canceled'); + assert.equal(error.details?.timeoutMs, 100); + assert.equal(error.details?.executed, 2); + assert.deepEqual(error.details?.partialResults, [ + { command: 'press' }, + { command: 'fill' }, + ]); + assert.equal(error.details?.dispatched, 'unknown'); + assert.deepEqual(error.details?.source, { path: '/tmp/workflow.ad', line: 4 }); + assert.equal(error.details?.diagnosticId, 'diag-4'); + assert.equal(error.details?.logPath, '/tmp/request.log'); + return true; + }, + ); + } finally { + vi.useRealTimers(); + } +}); + +test('a timer firing after explicit cancellation does not relabel it as a deadline', async () => { + vi.useFakeTimers(); + try { + await assert.rejects( + withRequestDeadline('explicit-first', 100, async () => { + markRequestCanceled('explicit-first'); + await vi.advanceTimersByTimeAsync(100); + throw getRequestSignal('explicit-first')?.reason; + }), + (error: unknown) => + isRequestCanceledError(error) && (error as AppError).details?.reason === 'request_canceled', + ); + } finally { + vi.useRealTimers(); + } +}); + test.each([false, true])( 'a throw at the wall-clock cutoff observes the deadline and preserves prior cancellation=%s', async (canceled) => { @@ -168,12 +261,12 @@ test.each([false, true])( throw new Error('synchronous operation failed after blocking the event loop'); }), (error: unknown) => { - assert.equal(error, signal.reason); - if (canceled) return isRequestCanceledError(error); + if (canceled) return error === signal.reason && isRequestCanceledError(error); return ( error instanceof AppError && error.details?.reason === 'daemon_request_timeout' && - error.details.timeoutMs === 100 + error.details.timeoutMs === 100 && + isRequestCanceledError(signal.reason) ); }, ); diff --git a/packages/host-kit/src/internal/request-cancel.ts b/packages/host-kit/src/internal/request-cancel.ts index f720527d1b..ec56b159ba 100644 --- a/packages/host-kit/src/internal/request-cancel.ts +++ b/packages/host-kit/src/internal/request-cancel.ts @@ -1,4 +1,9 @@ -import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; +import { + AppError, + createRequestCanceledError, + isRequestCanceledDetails, + isRequestCanceledError, +} from '@agent-device/kernel/errors'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { runtimeSlot } from '../runtime-slots.ts'; @@ -145,6 +150,29 @@ export function throwIfRequestCanceled(requestId: string | undefined): void { } } +const DEADLINE_MESSAGE = 'Daemon request deadline exceeded'; +const DEADLINE_HINT = + 'Check completed steps and the current device state before retrying the timed-out operation.'; + +function deadlineFailure(error: unknown, timeoutMs: number, requestId: string): AppError { + const prior = error instanceof AppError ? error.details : undefined; + return new AppError( + 'COMMAND_FAILED', + DEADLINE_MESSAGE, + { + ...prior, + ...(typeof prior?.reason === 'string' && prior.reason !== 'daemon_request_timeout' + ? { causeReason: prior.reason } + : {}), + reason: 'daemon_request_timeout', + timeoutMs, + requestId, + hint: isRequestCanceledError(error) ? DEADLINE_HINT : (prior?.hint ?? DEADLINE_HINT), + }, + error, + ); +} + /** Keeps a daemon deadline on the request's existing cancellation signal until its work settles. */ export async function withRequestDeadline( requestId: string, @@ -156,24 +184,27 @@ export async function withRequestDeadline( : registerRequestAbort(requestId); const controller = requestAbortControllers().get(requestId)!; const deadlineMs = Date.now() + timeoutMs; - const deadlineError = new AppError('COMMAND_FAILED', 'Daemon request deadline exceeded', { - reason: 'daemon_request_timeout', - timeoutMs, - requestId, - }); - const expire = () => controller.abort(deadlineError); + let deadlineCanceled = false; + const expire = () => { + if (controller.signal.aborted) return; + deadlineCanceled = true; + controller.abort(createRequestCanceledError({ timeoutMs, requestId })); + }; const timer = setTimeout(expire, timeoutMs); try { controller.signal.throwIfAborted(); const result = await task(); if (Date.now() >= deadlineMs) expire(); - if (controller.signal.reason === deadlineError) { + if (deadlineCanceled) { return result.ok ? result : { ...result, error: { ...result.error, + ...(isRequestCanceledDetails(result.error.details) + ? { message: DEADLINE_MESSAGE, hint: DEADLINE_HINT } + : {}), details: { ...result.error.details, ...(typeof result.error.details?.reason === 'string' && @@ -190,6 +221,7 @@ export async function withRequestDeadline( return result; } catch (error) { if (Date.now() >= deadlineMs) expire(); + if (deadlineCanceled) throw deadlineFailure(error, timeoutMs, requestId); controller.signal.throwIfAborted(); throw error; } finally { diff --git a/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts index 81c1688996..e08b706d42 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import net from 'node:net'; import type { ExecResult } from '@agent-device/host-kit/command'; +import { getRequestSignal, withRequestDeadline } from '@agent-device/host-kit/request'; import { createRequestCanceledError, isRequestCanceledError } from '@agent-device/kernel/errors'; import { withKeyedLock } from '@agent-device/kernel/keyed-lock'; import { afterEach, test, vi } from 'vitest'; @@ -298,6 +299,38 @@ test('a canceled exchange whose command reached the runner settles only once its assert.equal(session.commandCharges.hasOutstandingCharges, false); }); +test('a daemon deadline follows the ordinary canceled exchange journal drain', async () => { + server = await startFakeRunnerServer({ + longPress: [{ kind: 'hold' }], + status: [journalState('started'), journalState('completed')], + }); + const runner = server; + const session = sessionFor(runner.port); + const requestId = `deadline-journal:${runner.port}`; + + await assert.rejects( + withRequestDeadline(requestId, 25, async () => { + await executeRunnerExchange( + IOS_SIMULATOR, + session, + LONG_PRESS, + undefined, + 10_000, + async () => {}, + getRequestSignal(requestId), + ); + return { ok: true }; + }), + (error: unknown) => + error instanceof Error && + 'details' in error && + (error.details as { reason?: string }).reason === 'daemon_request_timeout', + ); + const commandId = sentCommandId(runner, 'longPress'); + assert.deepEqual(statusPolls(runner), [commandId, commandId]); + assert.equal(session.commandCharges.hasOutstandingCharges, false); +}); + test('a request on the same session lock starts only after the canceled command is terminal', async () => { server = await startFakeRunnerServer({ longPress: [{ kind: 'hold' }], diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index 41e84f78f4..f5e2f67667 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -53,7 +53,7 @@ function buildBatchCliDetail(): string { const prose = [ 'Each step is {"command":"","input":{...}}, and input is that command\'s structured input object rather than its terminal spelling: a CLI positional becomes a named field (target, text, direction) and a CLI flag becomes a camelCase key (--settle -> "settle":true, -i -> "interactiveOnly":true). There is no positional step form; args, argv, positionals, and flags are not step fields. agent-device help lists arguments in CLI spelling only. The examples below carry the structured spelling; over MCP, the command tool schema states it in full.', 'Steps run serially in one daemon request against the same session, in order. Mutating UI verbs are included (press, click, fill, longpress, scroll, back), which is where the round-trip saving is; --on-error stop halts at the first failing step.', - 'The whole batch has a 60-second wall-clock budget. A step that settles after cancellation returns COMMAND_FAILED with details.reason daemon_request_timeout; the daemon remains available. A close step may already have ended its session. Failure preserves executed, partialResults, and dispatch status. A completed batch returns success even if it settles just after the deadline. A step that ignores cancellation can delay the result past the client timeout. Split longer sequences into separate requests.', + 'The whole batch has a 60-second work budget inside a 90-second client timeout. The running step receives ordinary request cancellation at the budget. When it settles, failure returns COMMAND_FAILED with details.reason daemon_request_timeout and preserves executed, partialResults, and dispatch status. The daemon and other sessions stay available if it responds; a close step may already have ended its session. A completed batch returns success even if it settles just after the deadline. Cancellation does not prove native device work stopped. A step that ignores cancellation beyond the 30-second margin may cause a transport timeout; an unresponsive daemon retains the client liveness-based reset. Split longer sequences into separate requests.', `Available through batch: ${[...STRUCTURED_BATCH_COMMAND_NAMES].sort().join(', ')}.`, 'Every other command is excluded: batch and replay never nest, and session, daemon, connection, and host tooling commands own lifecycle a batch request cannot carry. Run those on their own.', ].join(' '); diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index c6f8a103bb..e516460f76 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -299,7 +299,7 @@ export const replayCommandFacet = defineCommandFacet({ text: { summary: 'Replay a recorded session or Maestro flow', cliDetail: - 'Replay has a 60-second wall-clock budget; --timeout changes it. A step that settles after cancellation returns a typed failure with details.reason daemon_request_timeout and details.timeoutMs; the daemon remains available. An authored close may already have ended its session. Failure preserves the failing script path and line. A completed replay returns success even if it settles just after the deadline. The client allows another 30 seconds for the result. A step that ignores cancellation can delay the result past the client timeout. For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively. For native .ad scripts, --keep-session suppresses exactly an authored terminal close so you can continue interactively. replay export converts compatible actions to Maestro YAML locally, including app switches with explicit launchApp.appId targets, deep links (including tel: and mailto:) as openLink, and home as pressKey: Home.', + 'Replay has a 60-second work budget inside a 90-second client timeout; --timeout sets a work budget of X and client timeout of X + 30000 ms. The running step receives ordinary request cancellation at the budget. When it settles, failure returns details.reason daemon_request_timeout and details.timeoutMs, preserving the failing script path and line. The daemon and other sessions stay available if it responds; an authored close may already have ended its session. A completed replay returns success even if it settles just after the deadline. Cancellation does not prove native device work stopped. A step that ignores cancellation beyond the 30-second margin may cause a transport timeout; an unresponsive daemon retains the client liveness-based reset. Direct test keeps its own timeout behavior. For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively. For native .ad scripts, --keep-session suppresses exactly an authored terminal close so you can continue interactively. replay export converts compatible actions to Maestro YAML locally, including app switches with explicit launchApp.appId targets, deep links (including tel: and mailto:) as openLink, and home as pressKey: Home.', }, metadata: replayCommandMetadata, run: (client, input) => client.replay.run(withCommandRuntimeHints(input)), diff --git a/test/integration/provider-scenarios/composite-deadline.test.ts b/test/integration/provider-scenarios/composite-deadline.test.ts index 2f026f6d7c..6d9a42862a 100644 --- a/test/integration/provider-scenarios/composite-deadline.test.ts +++ b/test/integration/provider-scenarios/composite-deadline.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; +import { isRequestCanceledError } from '@agent-device/kernel/errors'; import type { AndroidAdbExecutorResult, AndroidAdbProvider, @@ -79,6 +81,77 @@ function deadlineAdbProvider(state: DeadlineAdbState): AndroidAdbProvider { }; } +test('routed batch install reports its deadline and does not dispatch a later step', async () => { + let installStarted!: () => void; + const started = new Promise((resolve) => { + installStarted = resolve; + }); + let installSignal: AbortSignal | undefined; + const actions: string[] = []; + const daemon = await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + androidAdbProvider: () => ({ + exec: async (args) => { + if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'tap') { + actions.push('press'); + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'start') + return { exitCode: 0, stdout: 'Starting: Intent', stderr: '' }; + return respondToAndroidSettingsAdbCommand([...args], '', '', {}); + }, + install: async (_apk, options) => { + actions.push('install'); + installSignal = options?.signal; + installStarted(); + assert.ok(installSignal, 'install must receive the routed request signal'); + return await awaitGestureAbort(installSignal); + }, + }), + }); + try { + await daemon.client().apps.open({ + app: 'com.android.settings', + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + const apkPath = path.join(daemon.stateDir, 'deadline-install.apk'); + fs.writeFileSync(apkPath, 'fixture apk'); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + const pending = daemon.callCommand('batch', [], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + batchSteps: [ + { command: 'press', positionals: ['20', '30'] }, + { command: 'install', positionals: [apkPath] }, + { command: 'press', positionals: ['40', '50'] }, + ], + }); + await Promise.race([ + started, + pending.then((response) => assert.fail(JSON.stringify(response.json))), + ]); + assert.deepEqual(actions, ['press', 'install']); + await vi.advanceTimersByTimeAsync(60_000); + const response = await pending; + assert.equal(response.json.error?.data?.details?.reason, 'daemon_request_timeout'); + expect(response.json.error.data.details).toMatchObject({ + timeoutMs: 60_000, + executed: 1, + total: 3, + step: 2, + command: 'install', + partialResults: [{ step: 1, command: 'press', ok: true }], + }); + assert.equal(installSignal?.aborted, true); + assert.equal(isRequestCanceledError(installSignal.reason), true); + assert.deepEqual(actions, ['press', 'install']); + } finally { + vi.useRealTimers(); + await daemon.close(); + } +}); + test.each(['batch', 'replay'])( '%s deadline cancels Android longpress instrumentation and preserves its session', async (command) => { diff --git a/website/docs/docs/batching.md b/website/docs/docs/batching.md index 6dbdd5250e..8d1d167980 100644 --- a/website/docs/docs/batching.md +++ b/website/docs/docs/batching.md @@ -7,13 +7,17 @@ title: Batching Use `batch` to run several commands in one daemon request. It saves round trips when you already know the next sequence of actions. -The whole batch has a 60-second wall-clock budget. If an in-progress step settles after cancellation, -the batch returns `COMMAND_FAILED` with `details.reason: daemon_request_timeout`. The daemon stays -available. A `close` step may already have ended its session. A failed batch keeps +The whole batch has a 60-second wall-clock budget within a 90-second client timeout. At the +budget, the running step receives the same cancellation as a canceled request. When that step +settles, a failed batch returns `COMMAND_FAILED` with `details.reason: daemon_request_timeout`. +The daemon and other sessions stay available if it responds to the client. A `close` step may +already have ended its session. A failed batch keeps `details.executed`, `details.partialResults`, and its dispatch status, so you can continue from the failing step. A batch that completes every step returns success even if it settles shortly -after the deadline. A step that ignores cancellation -can delay the result past the client timeout. Split longer sequences into separate requests. +after the deadline. Cancellation does not prove that native device work has stopped. If a step +ignores cancellation beyond the remaining 30 seconds, the client may report a transport timeout. +For a daemon that does not respond, the client's existing liveness check can reset it. Split +longer sequences into separate requests. ## Run a batch from the CLI diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index 113107379f..1fbe4e5cad 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -6,14 +6,17 @@ title: Replay & E2E Testing Record a session as an `.ad` script, then run it again with `replay` or run a folder of scripts as an E2E suite with `test`. -`replay` has a 60-second wall-clock budget. Use `replay workflow.ad --timeout 300000` to allow -five minutes. The client allows another 30 seconds for the result. If the operation budget -expires and an in-progress step settles after cancellation, a failed replay includes -`details.reason: daemon_request_timeout` and `details.timeoutMs`. The daemon stays available. An +`replay` has a 60-second wall-clock budget within a 90-second client timeout. Use +`replay workflow.ad --timeout 300000` for a five-minute operation budget and a five-minute, +30-second client timeout. At the budget, the running step receives the same cancellation as a +canceled request. When it settles, a failed replay includes `details.reason: daemon_request_timeout` +and `details.timeoutMs`. The daemon and other sessions stay available if it responds to the client. An authored `close` may already have ended its session. A failed replay keeps the failing script path and line. A replay that completes every step returns success even if it settles shortly after the -deadline. A step that ignores cancellation -can delay the result past the client timeout. +deadline. Cancellation does not prove that native device work has stopped. If a step ignores +cancellation beyond the remaining 30 seconds, the client may report a transport timeout. For a +daemon that does not respond, the client's existing liveness check can reset it. Direct `test` +runs keep their own timeout behavior. To run Maestro YAML flows or export `.ad` scripts to Maestro YAML, see [Run Maestro flows](/docs/run-maestro-flows). From d7465b0945ada4c18e6b88b7a167abe1a7b4a7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 14:34:14 +0200 Subject: [PATCH 09/12] test: route Apple deadline drain regression through integration suite --- .../runner/__tests__/runner-exchange.test.ts | 33 -------- .../apple-deadline-journal.test.ts | 77 +++++++++++++++++++ 2 files changed, 77 insertions(+), 33 deletions(-) create mode 100644 test/integration/provider-scenarios/apple-deadline-journal.test.ts diff --git a/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts index e08b706d42..81c1688996 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import net from 'node:net'; import type { ExecResult } from '@agent-device/host-kit/command'; -import { getRequestSignal, withRequestDeadline } from '@agent-device/host-kit/request'; import { createRequestCanceledError, isRequestCanceledError } from '@agent-device/kernel/errors'; import { withKeyedLock } from '@agent-device/kernel/keyed-lock'; import { afterEach, test, vi } from 'vitest'; @@ -299,38 +298,6 @@ test('a canceled exchange whose command reached the runner settles only once its assert.equal(session.commandCharges.hasOutstandingCharges, false); }); -test('a daemon deadline follows the ordinary canceled exchange journal drain', async () => { - server = await startFakeRunnerServer({ - longPress: [{ kind: 'hold' }], - status: [journalState('started'), journalState('completed')], - }); - const runner = server; - const session = sessionFor(runner.port); - const requestId = `deadline-journal:${runner.port}`; - - await assert.rejects( - withRequestDeadline(requestId, 25, async () => { - await executeRunnerExchange( - IOS_SIMULATOR, - session, - LONG_PRESS, - undefined, - 10_000, - async () => {}, - getRequestSignal(requestId), - ); - return { ok: true }; - }), - (error: unknown) => - error instanceof Error && - 'details' in error && - (error.details as { reason?: string }).reason === 'daemon_request_timeout', - ); - const commandId = sentCommandId(runner, 'longPress'); - assert.deepEqual(statusPolls(runner), [commandId, commandId]); - assert.equal(session.commandCharges.hasOutstandingCharges, false); -}); - test('a request on the same session lock starts only after the canceled command is terminal', async () => { server = await startFakeRunnerServer({ longPress: [{ kind: 'hold' }], diff --git a/test/integration/provider-scenarios/apple-deadline-journal.test.ts b/test/integration/provider-scenarios/apple-deadline-journal.test.ts new file mode 100644 index 0000000000..173cd656f5 --- /dev/null +++ b/test/integration/provider-scenarios/apple-deadline-journal.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import type { ExecResult } from '@agent-device/host-kit/command'; +import { getRequestSignal, withRequestDeadline } from '@agent-device/host-kit/request'; +import { AppError } from '@agent-device/kernel/errors'; +import { executeRunnerExchange } from '../../../packages/platform-apple/src/runner/runner-exchange.ts'; +import { + RunnerCommandAccounting, + type RunnerSession, +} from '../../../packages/platform-apple/src/runner/runner-session-types.ts'; +import { IOS_SIMULATOR } from '../../../packages/platform-apple/src/runner/__tests__/device-fixtures.ts'; +import { startFakeRunnerServer } from '../../../packages/platform-apple/src/runner/__tests__/fake-runner-server.ts'; +import { + installAppleRunnerTestHost, + loadAppleRunnerHost, +} from '../../../packages/platform-apple/src/runner/test-host.ts'; +import { test } from 'vitest'; + +test('a daemon deadline follows the ordinary canceled Apple runner journal drain', async () => { + installAppleRunnerTestHost(await loadAppleRunnerHost()); + const runner = await startFakeRunnerServer({ + longPress: [{ kind: 'hold' }], + status: [ + { kind: 'ok', data: { lifecycleState: 'started' } }, + { kind: 'ok', data: { lifecycleState: 'completed' } }, + ], + }); + try { + const session: RunnerSession = { + sessionId: `deadline-journal:${runner.port}`, + device: IOS_SIMULATOR, + deviceId: IOS_SIMULATOR.id, + endpoint: { port: runner.port, token: 'runner-token-fixture' }, + xctestrunPath: '/tmp/fake.xctestrun', + jsonPath: '/tmp/fake.json', + testPromise: new Promise(() => {}), + child: { pid: process.pid, exitCode: null }, + state: 'ready', + commandCharges: new RunnerCommandAccounting(), + }; + const requestId = `deadline-journal:${runner.port}`; + + await assert.rejects( + withRequestDeadline(requestId, 25, async () => { + await executeRunnerExchange( + IOS_SIMULATOR, + session, + { + command: 'longPress', + x: 10, + y: 10, + durationMs: 5_000, + appBundleId: 'com.example.app', + }, + undefined, + 10_000, + async () => {}, + getRequestSignal(requestId), + ); + return { ok: true }; + }), + (error: unknown) => + error instanceof AppError && error.details?.reason === 'daemon_request_timeout', + ); + const commandId = runner.requests.find((request) => request.command === 'longPress')?.body + .commandId; + assert.ok(commandId); + assert.deepEqual( + runner.requests + .filter((request) => request.command === 'status') + .map((request) => request.body.statusCommandId), + [commandId, commandId], + ); + assert.equal(session.commandCharges.hasOutstandingCharges, false); + } finally { + await runner.close(); + } +}); From 861087e322a79480ce89a0b321a74311dcf9066d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 14:38:05 +0200 Subject: [PATCH 10/12] test: colocate Apple deadline drain with runner host --- .../runner-host-deadline-journal.test.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) rename test/integration/provider-scenarios/apple-deadline-journal.test.ts => packages/platform-apple/src/core/__tests__/runner-host-deadline-journal.test.ts (79%) diff --git a/test/integration/provider-scenarios/apple-deadline-journal.test.ts b/packages/platform-apple/src/core/__tests__/runner-host-deadline-journal.test.ts similarity index 79% rename from test/integration/provider-scenarios/apple-deadline-journal.test.ts rename to packages/platform-apple/src/core/__tests__/runner-host-deadline-journal.test.ts index 173cd656f5..e1643cd135 100644 --- a/test/integration/provider-scenarios/apple-deadline-journal.test.ts +++ b/packages/platform-apple/src/core/__tests__/runner-host-deadline-journal.test.ts @@ -2,17 +2,11 @@ import assert from 'node:assert/strict'; import type { ExecResult } from '@agent-device/host-kit/command'; import { getRequestSignal, withRequestDeadline } from '@agent-device/host-kit/request'; import { AppError } from '@agent-device/kernel/errors'; -import { executeRunnerExchange } from '../../../packages/platform-apple/src/runner/runner-exchange.ts'; -import { - RunnerCommandAccounting, - type RunnerSession, -} from '../../../packages/platform-apple/src/runner/runner-session-types.ts'; -import { IOS_SIMULATOR } from '../../../packages/platform-apple/src/runner/__tests__/device-fixtures.ts'; -import { startFakeRunnerServer } from '../../../packages/platform-apple/src/runner/__tests__/fake-runner-server.ts'; -import { - installAppleRunnerTestHost, - loadAppleRunnerHost, -} from '../../../packages/platform-apple/src/runner/test-host.ts'; +import { executeRunnerExchange } from '../../runner/runner-exchange.ts'; +import { RunnerCommandAccounting, type RunnerSession } from '../../runner/runner-session-types.ts'; +import { IOS_SIMULATOR } from '../../runner/__tests__/device-fixtures.ts'; +import { startFakeRunnerServer } from '../../runner/__tests__/fake-runner-server.ts'; +import { installAppleRunnerTestHost, loadAppleRunnerHost } from '../../runner/test-host.ts'; import { test } from 'vitest'; test('a daemon deadline follows the ordinary canceled Apple runner journal drain', async () => { From 9c61f5a9b1f6c4f44afbe1cf0c40dc9b36d2c490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 14:39:46 +0200 Subject: [PATCH 11/12] refactor: isolate daemon deadline response projection --- .../host-kit/src/internal/request-cancel.ts | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/host-kit/src/internal/request-cancel.ts b/packages/host-kit/src/internal/request-cancel.ts index ec56b159ba..f9c6713ab1 100644 --- a/packages/host-kit/src/internal/request-cancel.ts +++ b/packages/host-kit/src/internal/request-cancel.ts @@ -173,6 +173,28 @@ function deadlineFailure(error: unknown, timeoutMs: number, requestId: string): ); } +function projectDeadlineResponse(result: DaemonResponse, timeoutMs: number): DaemonResponse { + if (result.ok) return result; + const priorReason = result.error.details?.reason; + return { + ...result, + error: { + ...result.error, + ...(isRequestCanceledDetails(result.error.details) + ? { message: DEADLINE_MESSAGE, hint: DEADLINE_HINT } + : {}), + details: { + ...result.error.details, + ...(typeof priorReason === 'string' && priorReason !== 'daemon_request_timeout' + ? { causeReason: priorReason } + : {}), + reason: 'daemon_request_timeout', + timeoutMs, + }, + }, + }; +} + /** Keeps a daemon deadline on the request's existing cancellation signal until its work settles. */ export async function withRequestDeadline( requestId: string, @@ -195,28 +217,7 @@ export async function withRequestDeadline( controller.signal.throwIfAborted(); const result = await task(); if (Date.now() >= deadlineMs) expire(); - if (deadlineCanceled) { - return result.ok - ? result - : { - ...result, - error: { - ...result.error, - ...(isRequestCanceledDetails(result.error.details) - ? { message: DEADLINE_MESSAGE, hint: DEADLINE_HINT } - : {}), - details: { - ...result.error.details, - ...(typeof result.error.details?.reason === 'string' && - result.error.details.reason !== 'daemon_request_timeout' - ? { causeReason: result.error.details.reason } - : {}), - reason: 'daemon_request_timeout', - timeoutMs, - }, - }, - }; - } + if (deadlineCanceled) return projectDeadlineResponse(result, timeoutMs); controller.signal.throwIfAborted(); return result; } catch (error) { From 70b86fa1800d40b0fd4b891be0d385f164252be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 15:25:50 +0200 Subject: [PATCH 12/12] test: synchronize Apple deadline journal regression --- .../runner-host-deadline-journal.test.ts | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/packages/platform-apple/src/core/__tests__/runner-host-deadline-journal.test.ts b/packages/platform-apple/src/core/__tests__/runner-host-deadline-journal.test.ts index e1643cd135..4afc108cfd 100644 --- a/packages/platform-apple/src/core/__tests__/runner-host-deadline-journal.test.ts +++ b/packages/platform-apple/src/core/__tests__/runner-host-deadline-journal.test.ts @@ -7,7 +7,7 @@ import { RunnerCommandAccounting, type RunnerSession } from '../../runner/runner import { IOS_SIMULATOR } from '../../runner/__tests__/device-fixtures.ts'; import { startFakeRunnerServer } from '../../runner/__tests__/fake-runner-server.ts'; import { installAppleRunnerTestHost, loadAppleRunnerHost } from '../../runner/test-host.ts'; -import { test } from 'vitest'; +import { test, vi } from 'vitest'; test('a daemon deadline follows the ordinary canceled Apple runner journal drain', async () => { installAppleRunnerTestHost(await loadAppleRunnerHost()); @@ -18,6 +18,19 @@ test('a daemon deadline follows the ordinary canceled Apple runner journal drain { kind: 'ok', data: { lifecycleState: 'completed' } }, ], }); + vi.useFakeTimers({ toFake: ['Date'] }); + const realSetTimeout = globalThis.setTimeout; + let fireDeadline: (() => void) | undefined; + const timerSpy = vi + .spyOn(globalThis, 'setTimeout') + .mockImplementation((callback, delay, ...args) => { + if (fireDeadline === undefined) { + assert.equal(delay, 25); + fireDeadline = () => callback(...args); + return realSetTimeout(() => {}, delay); + } + return realSetTimeout(callback, delay, ...args); + }); try { const session: RunnerSession = { sessionId: `deadline-journal:${runner.port}`, @@ -33,25 +46,39 @@ test('a daemon deadline follows the ordinary canceled Apple runner journal drain }; const requestId = `deadline-journal:${runner.port}`; + const deadlineResult = withRequestDeadline(requestId, 25, async () => { + await executeRunnerExchange( + IOS_SIMULATOR, + session, + { + command: 'longPress', + x: 10, + y: 10, + durationMs: 5_000, + appBundleId: 'com.example.app', + }, + undefined, + 10_000, + async () => {}, + getRequestSignal(requestId), + ); + return { ok: true }; + }); + deadlineResult.catch(() => {}); + + const giveUpAtMs = performance.now() + 2_000; + while (!runner.requests.some((request) => request.command === 'longPress')) { + if (performance.now() >= giveUpAtMs) { + throw new Error('The fake runner did not admit longPress before the deadline advanced.'); + } + await new Promise((resolve) => setImmediate(resolve)); + } + assert.ok(fireDeadline); + vi.setSystemTime(Date.now() + 25); + fireDeadline(); + await assert.rejects( - withRequestDeadline(requestId, 25, async () => { - await executeRunnerExchange( - IOS_SIMULATOR, - session, - { - command: 'longPress', - x: 10, - y: 10, - durationMs: 5_000, - appBundleId: 'com.example.app', - }, - undefined, - 10_000, - async () => {}, - getRequestSignal(requestId), - ); - return { ok: true }; - }), + deadlineResult, (error: unknown) => error instanceof AppError && error.details?.reason === 'daemon_request_timeout', ); @@ -66,6 +93,8 @@ test('a daemon deadline follows the ordinary canceled Apple runner journal drain ); assert.equal(session.commandCharges.hasOutstandingCharges, false); } finally { + timerSpy.mockRestore(); + vi.useRealTimers(); await runner.close(); } });