Skip to content

Commit bfbd74a

Browse files
committed
fix(wait): surface runner restart timeout evidence
1 parent 04758c9 commit bfbd74a

6 files changed

Lines changed: 191 additions & 18 deletions

File tree

packages/contracts/src/wait.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
export const WAIT_REASONS = {
1414
captureStalled: 'wait_capture_stalled',
1515
deadlineExceeded: 'wait_deadline_exceeded',
16+
runnerRestartExhausted: 'wait_runner_restart_exhausted',
1617
targetAbsent: 'wait_target_absent',
1718
stableTimeout: 'wait_stable_timeout',
1819
landmarkIdentityMismatch: 'wait_landmark_identity_mismatch',

packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,49 @@ test('mutating commands emit readiness recovery diagnostics after failed preflig
459459
assert.match(diagnostics, /"recovery":"session_restarted"/);
460460
});
461461

462+
test('mutating commands mark errors after a failed preflight runner restart', async () => {
463+
const staleSession = makeRunnerSession({ port: 8100, ready: true });
464+
const freshSession = makeRunnerSession({ port: 8101, ready: false });
465+
466+
mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession);
467+
mockExecuteRunnerCommandWithSession
468+
.mockRejectedValueOnce(
469+
new AppError('COMMAND_FAILED', 'fetch failed', {
470+
runnerReadinessPreflightFailed: true,
471+
}),
472+
)
473+
.mockRejectedValueOnce(
474+
new AppError('COMMAND_FAILED', 'request canceled', {
475+
diagnosticId: 'diag-restart',
476+
logPath: '/tmp/restart.ndjson',
477+
}),
478+
);
479+
480+
await assert.rejects(
481+
() =>
482+
runAppleRunnerCommand(
483+
IOS_SIMULATOR,
484+
{ command: 'tap', x: 120, y: 240 },
485+
{ logPath: '/tmp/runner.log' },
486+
),
487+
(error: unknown) => {
488+
assert.ok(error instanceof AppError);
489+
assert.equal(error.details?.runnerRestarted, true);
490+
assert.equal(
491+
error.details?.runnerRestartReason,
492+
'runner_readiness_preflight_failed_before_command_send',
493+
);
494+
assert.equal(error.details?.runnerRestartCommand, 'tap');
495+
assert.match(String(error.details?.runnerRestartCommandId), /^runner-/);
496+
assert.equal(error.details?.runnerInvalidatedSessionId, staleSession.sessionId);
497+
assert.equal(error.details?.runnerRestartSessionId, freshSession.sessionId);
498+
assert.equal(error.details?.diagnosticId, 'diag-restart');
499+
assert.equal(error.details?.logPath, '/tmp/restart.ndjson');
500+
return true;
501+
},
502+
);
503+
});
504+
462505
test('mutating commands do not restart or replay after command send failure', async () => {
463506
const session = makeRunnerSession({ port: 8100, ready: true });
464507

packages/platform-apple/src/runner/runner-lifecycle.ts

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,8 @@ async function restartSessionAndRunCommand(params: {
359359
const restartedSession = await ensureRunnerSession(device, {
360360
...options,
361361
cleanStaleBundles: true,
362+
}).catch((error: unknown) => {
363+
throw markRunnerRestartError(error, params);
362364
});
363365
commitRunnerRecycle(recycleKey);
364366
try {
@@ -386,21 +388,53 @@ async function restartSessionAndRunCommand(params: {
386388
} catch (retryErr) {
387389
const retryAppErr = asAppError(retryErr, 'COMMAND_FAILED');
388390
if (isRetryableRunnerError(retryAppErr)) {
389-
return await handleRunnerTransportErrorAfterCommandSend({
390-
device,
391-
session: restartedSession,
392-
command,
393-
transportError: retryAppErr,
394-
options,
395-
signal,
396-
invalidationReason: 'transport_error_after_retry_command_send',
397-
invalidateSession: invalidateRunnerSession,
398-
});
391+
try {
392+
return await handleRunnerTransportErrorAfterCommandSend({
393+
device,
394+
session: restartedSession,
395+
command,
396+
transportError: retryAppErr,
397+
options,
398+
signal,
399+
invalidationReason: 'transport_error_after_retry_command_send',
400+
invalidateSession: invalidateRunnerSession,
401+
});
402+
} catch (recoveryErr) {
403+
throw markRunnerRestartError(recoveryErr, params, restartedSession);
404+
}
399405
}
400-
throw retryErr;
406+
throw markRunnerRestartError(retryErr, params, restartedSession);
401407
}
402408
}
403409

410+
function markRunnerRestartError(
411+
error: unknown,
412+
params: Pick<
413+
Parameters<typeof restartSessionAndRunCommand>[0],
414+
'session' | 'command' | 'options' | 'restartReason'
415+
>,
416+
restartedSession?: RunnerSession,
417+
): unknown {
418+
if (!(error instanceof AppError)) return error;
419+
return new AppError(
420+
error.code,
421+
error.message,
422+
{
423+
...(error.details ?? {}),
424+
runnerRestarted: true,
425+
runnerRestartReason: params.restartReason,
426+
runnerRestartCommand: params.command.command,
427+
...(params.command.commandId ? { runnerRestartCommandId: params.command.commandId } : {}),
428+
runnerInvalidatedSessionId: params.session.sessionId,
429+
...(restartedSession ? { runnerRestartSessionId: restartedSession.sessionId } : {}),
430+
...(error.details?.logPath === undefined && params.options.logPath
431+
? { logPath: params.options.logPath }
432+
: {}),
433+
},
434+
error.cause ?? error,
435+
);
436+
}
437+
404438
async function runPrepareHealthCheck(
405439
device: DeviceInfo,
406440
session: RunnerSession,

src/commands/interaction/runtime/wait-deadline.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
export type WaitDeadlineResult<T> = { timedOut: false; value: T } | { timedOut: true };
1+
export type WaitDeadlineResult<T> =
2+
| { timedOut: false; value: T }
3+
| { timedOut: true; error?: unknown };
24

35
type AbortSignalSource = { signal?: AbortSignal };
46

@@ -37,7 +39,7 @@ export async function runWithinWaitDeadline<T>(
3739
return { timedOut: false, value };
3840
} catch (error) {
3941
if (deadlineExpired && !parentSignals.some((parent) => parent.aborted)) {
40-
return { timedOut: true };
42+
return { timedOut: true, error };
4143
}
4244
throw error;
4345
} finally {

src/commands/interaction/runtime/wait-polling.ts

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AppError } from '@agent-device/kernel/errors';
1+
import { AppError, type AppErrorDetails } from '@agent-device/kernel/errors';
22
import { WAIT_REASONS } from '@agent-device/contracts/wait';
33
import { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality';
44
import { selectorPollBudget } from '../../../core/selector-pipeline.ts';
@@ -15,12 +15,20 @@ import { runWithinWaitDeadline } from './wait-deadline.ts';
1515
*/
1616
export const DEFAULT_WAIT_TIMEOUT_MS = SELECTOR_PIPELINE_POLICIES.wait.poll.defaultTimeoutMs;
1717

18-
export type WaitPollDeadline = 'capture-stalled' | 'capture-truncated';
18+
export type WaitPollDeadline = 'capture-stalled' | 'capture-truncated' | 'runner-restart-exhausted';
1919

2020
export type WaitFailureEvidence = {
2121
timeoutMs: number;
2222
readableCaptures: number;
2323
waitedMs: number;
24+
runnerRestarted?: true;
25+
runnerRestartReason?: string;
26+
runnerRestartCommand?: string;
27+
runnerRestartCommandId?: string;
28+
runnerInvalidatedSessionId?: string;
29+
runnerRestartSessionId?: string;
30+
logPath?: string;
31+
diagnosticId?: string;
2432
};
2533

2634
type WaitPollingRuntime = {
@@ -62,6 +70,7 @@ export function createWaitPolling(
6270
const timeoutMs = requestedTimeoutMs ?? budget.defaultTimeoutMs;
6371
const startedAtMs = now(runtime);
6472
const unreadable = createUnreadablePollTracker();
73+
let timeoutEvidence: Partial<WaitFailureEvidence> = {};
6574
const remainingMs = () => Math.max(0, timeoutMs - (now(runtime) - startedAtMs));
6675

6776
return {
@@ -82,6 +91,8 @@ export function createWaitPolling(
8291
if (captureWasReadable) unreadable.recordReadableCapture();
8392
return result;
8493
}
94+
const runnerRestart = runnerRestartTimeoutEvidence(result.error);
95+
timeoutEvidence = runnerRestart ?? {};
8596
// A capture that only becomes readable after its deadline is not evidence for this wait.
8697
// Count only captures that completed before runWithinWaitDeadline returned a timeout.
8798
return {
@@ -90,16 +101,19 @@ export function createWaitPolling(
90101
// the poll index is not evidence. This remains true after one or more unreadable content
91102
// verdicts followed by a capture that consumes the remaining budget.
92103
deadline:
93-
unreadable.readableCaptures() === 0
94-
? ('capture-stalled' as const)
95-
: ('capture-truncated' as const),
104+
runnerRestart !== undefined
105+
? ('runner-restart-exhausted' as const)
106+
: unreadable.readableCaptures() === 0
107+
? ('capture-stalled' as const)
108+
: ('capture-truncated' as const),
96109
};
97110
},
98111
hasTimeRemaining: () => remainingMs() > 0,
99112
failureEvidence: (): WaitFailureEvidence => ({
100113
timeoutMs,
101114
readableCaptures: unreadable.readableCaptures(),
102115
waitedMs: now(runtime) - startedAtMs,
116+
...timeoutEvidence,
103117
}),
104118
rethrowIfNeverReadable: unreadable.rethrowIfNeverReadable,
105119
sleepUntilNextPoll: async () =>
@@ -119,6 +133,16 @@ function waitCaptureStalledError(message: string, evidence: WaitFailureEvidence)
119133
});
120134
}
121135

136+
function waitRunnerRestartExhaustedError(message: string, evidence: WaitFailureEvidence): AppError {
137+
return new AppError('COMMAND_FAILED', message, {
138+
reason: WAIT_REASONS.runnerRestartExhausted,
139+
waitRunnerRestartExhausted: true,
140+
...evidence,
141+
retriable: true,
142+
hint: 'An iOS runner restart consumed the wait timeout before a readable snapshot completed. Inspect the diagnostics log for the runner invalidation/restart sequence, then retry.',
143+
});
144+
}
145+
122146
function waitDeadlineExceededError(message: string, evidence: WaitFailureEvidence): AppError {
123147
return new AppError('COMMAND_FAILED', message, {
124148
reason: WAIT_REASONS.deadlineExceeded,
@@ -140,6 +164,9 @@ export function waitTimeoutError(
140164
deadline: WaitPollDeadline | undefined,
141165
): AppError {
142166
const evidence = polling.failureEvidence();
167+
if (deadline === 'runner-restart-exhausted') {
168+
return waitRunnerRestartExhaustedError(message, evidence);
169+
}
143170
if (deadline === 'capture-stalled') return waitCaptureStalledError(message, evidence);
144171
if (deadline === 'capture-truncated') return waitDeadlineExceededError(message, evidence);
145172

@@ -149,6 +176,30 @@ export function waitTimeoutError(
149176
: waitTargetAbsentError(message, evidence);
150177
}
151178

179+
function runnerRestartTimeoutEvidence(error: unknown): Partial<WaitFailureEvidence> | undefined {
180+
if (!(error instanceof AppError)) return undefined;
181+
const details = error.details;
182+
if (details?.runnerRestarted !== true) return undefined;
183+
return {
184+
runnerRestarted: true,
185+
...copyStringDetail(details, 'runnerRestartReason'),
186+
...copyStringDetail(details, 'runnerRestartCommand'),
187+
...copyStringDetail(details, 'runnerRestartCommandId'),
188+
...copyStringDetail(details, 'runnerInvalidatedSessionId'),
189+
...copyStringDetail(details, 'runnerRestartSessionId'),
190+
...copyStringDetail(details, 'logPath'),
191+
...copyStringDetail(details, 'diagnosticId'),
192+
};
193+
}
194+
195+
function copyStringDetail<Key extends keyof WaitFailureEvidence>(
196+
details: AppErrorDetails | undefined,
197+
key: Key,
198+
): Pick<WaitFailureEvidence, Key> | {} {
199+
const value = details?.[key];
200+
return typeof value === 'string' ? ({ [key]: value } as Pick<WaitFailureEvidence, Key>) : {};
201+
}
202+
152203
function createUnreadablePollTracker(): UnreadablePollTracker {
153204
let readableCaptureCount = 0;
154205
let lastUnreadableError: unknown;

src/daemon/__tests__/wait-runtime.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test, vi } from 'vitest';
22
import { WAIT_REASONS } from '@agent-device/contracts/wait';
3+
import { AppError } from '@agent-device/kernel/errors';
34
import {
45
type DeviceBinding,
56
type RuntimeFacts,
@@ -532,6 +533,47 @@ test('a stalled capture reports capture-stalled with no readable captures', asyn
532533
expect(response.error.details?.readableCaptures).toBe(0);
533534
});
534535

536+
test('a runner restart that exhausts the wait reports typed restart evidence', async () => {
537+
const captureSnapshot = vi.fn(async (input: CaptureSnapshotInput) => {
538+
const signal = input.signal;
539+
if (!signal) throw new Error('the poll deadline never reached the platform');
540+
await new Promise<void>((resolve) => {
541+
if (signal.aborted) return resolve();
542+
signal.addEventListener('abort', () => resolve(), { once: true });
543+
});
544+
throw new AppError('COMMAND_FAILED', 'request canceled', {
545+
runnerRestarted: true,
546+
runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send',
547+
runnerRestartCommand: 'snapshot',
548+
runnerRestartCommandId: 'snapshot-1',
549+
runnerInvalidatedSessionId: 'session-old',
550+
runnerRestartSessionId: 'session-new',
551+
diagnosticId: 'diag-restart',
552+
logPath: '/tmp/restart.ndjson',
553+
});
554+
});
555+
const harness = waitRuntimeHarness({ captureSnapshot });
556+
557+
const { response } = await runWait(['text', 'Ready', '50'], harness);
558+
559+
expect(response.ok).toBe(false);
560+
if (response.ok) return;
561+
expect(response.error.details?.reason).toBe(WAIT_REASONS.runnerRestartExhausted);
562+
expect(response.error.details?.waitRunnerRestartExhausted).toBe(true);
563+
expect(response.error.details?.captureStalled).toBeUndefined();
564+
expect(response.error.details?.runnerRestarted).toBe(true);
565+
expect(response.error.details?.runnerRestartReason).toBe(
566+
'runner_readiness_preflight_failed_before_command_send',
567+
);
568+
expect(response.error.details?.runnerRestartCommand).toBe('snapshot');
569+
expect(response.error.details?.runnerRestartCommandId).toBe('snapshot-1');
570+
expect(response.error.details?.runnerInvalidatedSessionId).toBe('session-old');
571+
expect(response.error.details?.runnerRestartSessionId).toBe('session-new');
572+
expect(response.error.details?.diagnosticId).toBe('diag-restart');
573+
expect(response.error.details?.logPath).toBe('/tmp/restart.ndjson');
574+
expect(response.error.details?.readableCaptures).toBe(0);
575+
});
576+
535577
test('a readable capture that lacks the target stays target-absent, not capture-stalled', async () => {
536578
const harness = waitRuntimeHarness({
537579
nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }]],

0 commit comments

Comments
 (0)