Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions docs/adr/0012-interactive-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
## Status

Accepted (2026-07-10). Implemented on `main`, including the amendments folded into the decisions
below (#1264, #1269, #1271 stage 2, #1280, #1349); only decision 5's replay benchmark extension
remains deferred. The per-step landing record (PRs #1193-#1349) and the pre-acceptance migration
plan live in this file's git history.
below (#1264, #1269, #1271 stage 2, #1280, #1349, #1385); only decision 5's replay benchmark
extension remains deferred. The per-step landing record (PRs #1193-#1349) and the pre-acceptance
migration plan live in this file's git history.

## Rules at a glance

Expand All @@ -29,7 +29,9 @@ Normative summary, one entry per decision. The binding contracts, amendments, an
`identity-unverifiable` divergence, never a silent pick. Amendments: non-unique ids demote to
role+label (#1269); an identity-empty pressed container records its first labeled descendant,
double-guarded fail-closed (#1280); `wait` landmark identity and `is` coverage dispatch on the
`targetIdentityVerification` descriptor trait (#1349).
`targetIdentityVerification` descriptor trait (#1349); the pre-dispatch verification capture
bounds-retries a content-quality `capture-failed`/`sparse-snapshot` verdict on an app-launch race,
opt-in per call site (#1385).
4. **Divergence is a structured error, resumable by plan ordinal.** `ok:false` with code
`REPLAY_DIVERGENCE` and `details.divergence` v1: `kind` (`action-failure` | `selector-miss` |
`identity-mismatch` | `identity-unverifiable`), a bounded `screen` (same capture scope as plain
Expand Down Expand Up @@ -369,6 +371,41 @@ An old unannotated action remains executable without this check. All three diver
target-binding divergences reported before the device action. This is not general outcome verification:
`--verify` remains post-action change evidence with a different contract.

> **Amendment (#1385).** The capture this verification runs against (`captureDivergenceObservation`) is
> itself the pre-dispatch gate a step right after `open --relaunch` races: the app can still be
> launching/mounting when it lands, producing a transient content-quality verdict — `capture-failed`
> (Android's snapshot helper returns "insufficient foreground app content" while the app is mounting, and
> the capture path throws) or `sparse-snapshot` (iOS's private-AX fallback under load) — that is not a
> real divergence, only an unlucky capture. This capture now retries with a bounded backoff (fixed
> 7-entry delay list, 12s DELAY-ONLY budget — see below) before falling through to `identity-unverifiable`,
> mirroring the keep-polling semantics `wait`'s recorded-landmark identity verification (#1349) already
> applies on its own (post-resolution) path. The retry is opt-in per call site (`retryLaunchRace`), not a
> change to every `captureDivergenceObservation` caller: only the pre-dispatch verification gate in
> `verifyReplayActionTarget` races a launch this way — the post-failure diagnostic capture
> (`buildReplayFailureDivergence`) and the post-resolution guard-mismatch capture both follow an
> already-real failure, where retrying would only delay an already-decided divergence.
>
> The retry loop is further gated on the SAME content-quality-vs-mechanism-failure taxonomy #1381 draws
> for the wait keep-poll loop (`isUnreadableCaptureContentError`): the non-throwing `sparse-snapshot`
> verdict always retries (it is already a content-quality signal), but a thrown `capture-failed` only
> retries when the underlying error's `androidSnapshotHelperFailureReason` is one of the three literal
> codes `rejectAndroidHelperContentUnavailable` (`platforms/android/snapshot.ts`) attaches to a
> content-poor/system-window-only rejection — `empty-helper-output`, `system-window-only`,
> `content-poor-app-window` (mirroring `AndroidHelperContentRecoveryDecision['reason']`,
> `platforms/android/snapshot-content-recovery.ts`). This is deliberately narrower than the error's own
> generic `retriable` flag: Android's adb layer separately marks true mechanism failures retriable too
> (`connection_dropped`, `device_offline`, `server_version_mismatch` — an unchanged retry of the SAME adb
> command can succeed there), and a helper artifact permanently missing
> (`androidSnapshotHelperUnavailableError`) carries neither signal. A mechanism failure therefore still
> fails on the first attempt rather than spending the retry budget on a foregone conclusion, regardless of
> what its own `retriable` flag says.
>
> The 12s budget is a DELAY-ONLY bound, anchored at the first capture attempt: it caps how long this loop
> sleeps between retries, not how long any individual capture attempt itself may run (a capture already
> carries its own platform-level timeouts this loop does not shorten or re-implement). The fixed-length
> delay array is a separate, independent bound on attempt COUNT, so a mocked-instant `sleep` in unit tests
> cannot turn this into a real-time busy-loop.

### 4. Divergence wire contract and replay-only resume

**Divergence is a structured error, not success data.** The daemon returns `ok:false` with code
Expand Down
62 changes: 60 additions & 2 deletions src/daemon/handlers/__tests__/session-replay-divergence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => {
// in `snapshot-capture.ts` (`capturePostActionAwareSnapshot`) awaits; making it
// instant does not change control flow — the loop still runs, retries, and
// re-captures — so the test still proves two dispatches and use of the retried
// tree. No other test in this file triggers a sleep path.
// tree. The #1385 `retryLaunchRace` retry loop (`captureDivergenceObservation`)
// awaits this SAME `sleep`, further down in this file.
vi.mock('../../../utils/timeouts.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../utils/timeouts.ts')>();
return { ...actual, sleep: vi.fn(async () => {}) };
});

import { dispatchCommand } from '../../../core/dispatch.ts';
import type { RawSnapshotNode } from '../../../kernel/snapshot.ts';
import { AppError } from '../../../kernel/errors.ts';
import {
makeAndroidSession,
makeIosSession,
Expand All @@ -32,7 +34,10 @@ import {
walkNonRawAndroidFixture,
} from '../../../__tests__/test-utils/android-ui-hierarchy-fixtures.ts';
import { SessionStore } from '../../session-store.ts';
import { buildReplayFailureDivergence } from '../session-replay-divergence.ts';
import {
buildReplayFailureDivergence,
captureDivergenceObservation,
} from '../session-replay-divergence.ts';

const mockDispatchCommand = vi.mocked(dispatchCommand);

Expand Down Expand Up @@ -1135,3 +1140,56 @@ test.each([
expect(screen.refs.some((ref) => ref.label === 'Battery charging, 100 percent.')).toBe(false);
expect(screen.refs.some((ref) => ref.label === 'Wifi signal full.')).toBe(false);
});

// #1385 P2: the retry deadline is a DELAY-ONLY budget, not a per-attempt
// capture timeout — this loop does not itself bound how long a single
// capture attempt runs, only how much SLEEP time it spends between attempts
// (measured from the first attempt). This test proves that bound is real
// wall-clock behavior, not merely "run the fixed 7-entry delay list": each
// mocked capture attempt here advances the (fake) system clock by 5s to
// stand in for a slow capture, so the 12s deadline is exhausted after just 3
// attempts — far short of the 8 attempts (1 + 7 delays) the array alone would
// allow — proving the deadline, not the array length, is what stopped it.
test('captureDivergenceObservation retryLaunchRace: the 12s deadline bounds retries even when captures themselves consume wall-clock time', async () => {
vi.useFakeTimers();
try {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-deadline-'));
const sessionStore = new SessionStore(path.join(root, 'sessions'));
const sessionName = 'default';
sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' }));

mockDispatchCommand.mockImplementation(async () => {
vi.setSystemTime(new Date(Date.now() + 5_000));
throw new AppError(
'COMMAND_FAILED',
'Android snapshot helper returned insufficient foreground app content',
{ androidSnapshotHelperFailureReason: 'content-poor-app-window', retriable: true },
);
});

const action = {
ts: 0,
command: 'click',
positionals: ['label="Save"'],
flags: {},
result: { selectorChain: ['label="Save"', 'id="save"'] },
};

const observation = await captureDivergenceObservation({
session: sessionStore.get(sessionName)!,
sessionName,
sessionStore,
logPath: path.join(root, 'daemon.log'),
action,
retryLaunchRace: true,
});

expect(observation.state).toBe('unavailable');
// 1 initial attempt + 2 retries: the 3rd retry's pre-sleep remaining-budget
// check sees the deadline already passed (3 x 5s = 15s > 12s) and breaks
// BEFORE a 4th capture — not the 8 attempts the delay array alone permits.
expect(mockDispatchCommand).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
}
});
Loading
Loading