Skip to content
6 changes: 0 additions & 6 deletions contracts/fixtures/dispatch-disclosure.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,6 @@
"trigger": "Android blocking-dialog readiness tapped Close app and relaunched the app, then re-admission aborted the requested ref press",
"dispatched": "unknown"
},
{
"id": "daemon.refusal.selector-readiness-exhausted",
"producer": "daemon",
"trigger": "press with readinessTimeoutMs whose selector target never appears: the readiness wait (pollForSelectorReadiness) spends its budget over two or more polls and refuses with selector_not_found before any backend touch",
"dispatched": "no"
},
{
"id": "daemon.unclassified",
"producer": "daemon",
Expand Down
8 changes: 3 additions & 5 deletions packages/ad-replay/src/internal/runtime-port-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,9 @@ export type AdReplayStepRuntime = Readonly<{
* Captures a fresh snapshot (with the #1385 launch-race retry) and resolves
* the recorded target against it using the SAME lookup/matching a real
* dispatch would — daemon authority (`SessionStore`, the capture pipeline,
* tree helpers and the selectors package engine). A step whose dispatch
* would wait for its target under a readiness budget re-captures while the
* target does not match yet, under that same budget, so this gate never
* refuses a target the dispatch itself would have waited for. The last
* capture is the divergence's `screen`.
* tree helpers and the selectors package engine). A missing target is
* refused after this observation; scripts wait explicitly for loading
* screens. The capture is the divergence's `screen`.
*/
observeTarget(params: {
action: SessionAction;
Expand Down
163 changes: 11 additions & 152 deletions packages/capture-kit/src/observe-until.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import assert from 'node:assert/strict';
import { describe, test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality';
import { observeUntil, type ObservationClock } from './observe-until.ts';

/** A clock that advances only when the loop sleeps or a capture declares its own cost. */
Expand All @@ -21,16 +20,7 @@ function fakeClock(): ObservationClock & { advance(ms: number): void; slept: num
};
}

/** No per-capture deadline: the engine default. */
const UNBOUNDED_SCHEDULE = { intervalMs: 200, budgetMs: 1_000 } as const;
const SCHEDULE = { ...UNBOUNDED_SCHEDULE, captureDeadline: 'cancel' } as const;

/** The Android helper's content verdict: the capture ran but held no readable app content. */
function unreadableContent(): AppError {
return new AppError('COMMAND_FAILED', 'no readable content', {
androidSnapshotHelperFailureReason: 'system-window-only',
});
}
const SCHEDULE = { intervalMs: 200, budgetMs: 1_000 } as const;

describe('observeUntil', () => {
test('answers done on the poll whose verdict accepts, with the timeline', async () => {
Expand Down Expand Up @@ -72,28 +62,7 @@ describe('observeUntil', () => {
assert.equal(observed.waitedMs, 1_000);
});

test('rides out only the errors the caller classifies, and keeps polling', async () => {
const clock = fakeClock();
let captures = 0;
const observed = await observeUntil({
capture: async () => {
captures += 1;
if (captures === 1) throw unreadableContent();
return captures;
},
verdict: (latest) => ({ kind: 'done', result: latest }),
schedule: SCHEDULE,
rideOut: isUnreadableCaptureContentError,
clock,
});
assert.equal(observed.kind, 'done');
assert.deepEqual(
observed.polls.map((poll) => poll.outcome),
['rode-out', 'observed'],
);
});

test('fails on the first error the caller does not ride out', async () => {
test('fails on the first capture error', async () => {
const clock = fakeClock();
const failure = new AppError('COMMAND_FAILED', 'wedged');
const observed = await observeUntil({
Expand All @@ -102,7 +71,6 @@ describe('observeUntil', () => {
},
verdict: () => ({ kind: 'continue' }),
schedule: SCHEDULE,
rideOut: isUnreadableCaptureContentError,
clock,
});
assert.equal(observed.kind, 'failed');
Expand All @@ -113,47 +81,6 @@ describe('observeUntil', () => {
);
});

test('reports the last ridden-out error when every poll was ridden out', async () => {
const clock = fakeClock();
const failures: AppError[] = [];
const observed = await observeUntil<number, number>({
capture: async () => {
const failure = unreadableContent();
failures.push(failure);
throw failure;
},
verdict: (latest) => ({ kind: 'done', result: latest }),
schedule: SCHEDULE,
rideOut: isUnreadableCaptureContentError,
clock,
});
assert.equal(observed.kind, 'expired');
assert.equal(observed.kind === 'expired' && observed.last, undefined);
assert.equal(observed.kind === 'expired' && observed.lastError, failures.at(-1));
assert.ok(observed.polls.every((poll) => poll.outcome === 'rode-out'));
});

test('cancels and joins a capture still in flight at the deadline', async () => {
let aborted = false;
const observed = await observeUntil({
capture: (signal) =>
new Promise<number>((resolve) => {
signal.addEventListener('abort', () => {
aborted = true;
setTimeout(() => resolve(1), 5);
});
}),
verdict: () => ({ kind: 'done', result: true }),
schedule: { intervalMs: 5, budgetMs: 20, captureDeadline: 'cancel' },
});
assert.equal(observed.kind, 'stalled');
assert.equal(aborted, true);
assert.deepEqual(
observed.polls.map((poll) => poll.outcome),
['stalled'],
);
});

test('always completes minPolls even past the budget, so a quiet pair can form', async () => {
const clock = fakeClock();
let captures = 0;
Expand All @@ -167,7 +94,7 @@ describe('observeUntil', () => {
previous !== undefined
? { kind: 'done', result: [previous, latest] }
: { kind: 'continue' },
schedule: { ...UNBOUNDED_SCHEDULE, minPolls: 2 },
schedule: { ...SCHEDULE, minPolls: 2 },
clock,
});
assert.equal(observed.kind, 'done');
Expand Down Expand Up @@ -210,59 +137,7 @@ describe('observeUntil', () => {
});
});

describe('observeUntil cancellation', () => {
test('a signal aborted during the sleep ends the loop canceled before another capture', async () => {
const clock = fakeClock();
const controller = new AbortController();
let captures = 0;
await assert.rejects(
observeUntil({
capture: async () => {
captures += 1;
return captures;
},
verdict: () => ({ kind: 'continue' }),
schedule: UNBOUNDED_SCHEDULE,
signal: controller.signal,
clock: {
now: clock.now,
sleep: async (ms) => {
await clock.sleep(ms);
controller.abort();
},
},
}),
(error) => error instanceof AppError && error.details?.reason === 'request_canceled',
);
assert.equal(captures, 1);
});
});

describe('observeUntil budgetFrom first-capture', () => {
test('never bounds the first capture and spends the budget only on retries', async () => {
const clock = fakeClock();
let captures = 0;
const observed = await observeUntil({
capture: async () => {
captures += 1;
if (captures === 1) clock.advance(5_000);
return captures;
},
verdict: (latest) => (latest === 3 ? { kind: 'done', result: latest } : { kind: 'continue' }),
schedule: {
intervalMs: 200,
budgetMs: 1_000,
budgetFrom: 'first-capture',
captureDeadline: 'cancel',
},
clock,
});
assert.equal(observed.kind, 'done');
assert.equal(observed.polls.length, 3);
assert.equal(observed.polls[0]?.durationMs, 5_000);
assert.equal(observed.waitedMs, 5_400);
});

describe('observeUntil initial value', () => {
test('sleeps the interval between an initial observation and the first poll', async () => {
const clock = fakeClock();
const observed = await observeUntil({
Expand All @@ -271,7 +146,7 @@ describe('observeUntil budgetFrom first-capture', () => {
previous === undefined
? { kind: 'continue' }
: { kind: 'done', result: [previous, latest] },
schedule: { intervalMs: 200, budgetMs: 1_000, minPolls: 2, captureDeadline: 'cancel' },
schedule: { ...SCHEDULE, minPolls: 2 },
initial: 1,
clock,
});
Expand All @@ -281,8 +156,8 @@ describe('observeUntil budgetFrom first-capture', () => {
});
});

describe('observeUntil captureDeadline', () => {
/** A second capture that returns only after the remaining budget is spent, ignoring its signal. */
describe('observeUntil late capture', () => {
/** A second capture that returns only after the remaining budget is spent. */
function lateSecondCapture(clock: ReturnType<typeof fakeClock>) {
let captures = 0;
return async () => {
Expand All @@ -292,12 +167,12 @@ describe('observeUntil captureDeadline', () => {
};
}

test('by default judges a capture that finishes past the budget and can end done', async () => {
test('judges a capture that finishes past the budget and can end done', async () => {
const clock = fakeClock();
const observed = await observeUntil({
capture: lateSecondCapture(clock),
verdict: (latest) => (latest === 2 ? { kind: 'done', result: latest } : { kind: 'continue' }),
schedule: UNBOUNDED_SCHEDULE,
schedule: SCHEDULE,
clock,
});
assert.equal(observed.kind, 'done');
Expand All @@ -307,32 +182,16 @@ describe('observeUntil captureDeadline', () => {
);
});

test("by default ends expired, never stalled, when the late capture's verdict continues", async () => {
test("ends expired when the late capture's verdict continues", async () => {
const clock = fakeClock();
const observed = await observeUntil({
capture: lateSecondCapture(clock),
verdict: () => ({ kind: 'continue' }),
schedule: UNBOUNDED_SCHEDULE,
schedule: SCHEDULE,
clock,
});
assert.equal(observed.kind, 'expired');
assert.equal(observed.kind === 'expired' && observed.last, 2);
assert.equal(observed.polls.length, 2);
});

test("'cancel' ends stalled on a capture that finishes past its deadline", async () => {
const clock = fakeClock();
const observed = await observeUntil({
capture: lateSecondCapture(clock),
verdict: (latest) => (latest === 2 ? { kind: 'done', result: latest } : { kind: 'continue' }),
schedule: SCHEDULE,
clock,
});
assert.equal(observed.kind, 'stalled');
assert.equal(observed.kind === 'stalled' && observed.last, 1);
assert.deepEqual(
observed.polls.map((poll) => poll.outcome),
['observed', 'stalled'],
);
});
});
Loading
Loading