1- import { AppError } from '@agent-device/kernel/errors' ;
1+ import { AppError , type AppErrorDetails } from '@agent-device/kernel/errors' ;
22import { WAIT_REASONS } from '@agent-device/contracts/wait' ;
33import { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality' ;
44import { selectorPollBudget } from '../../../core/selector-pipeline.ts' ;
@@ -15,12 +15,20 @@ import { runWithinWaitDeadline } from './wait-deadline.ts';
1515 */
1616export 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
2020export 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
2634type 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+
122146function 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+
152203function createUnreadablePollTracker ( ) : UnreadablePollTracker {
153204 let readableCaptureCount = 0 ;
154205 let lastUnreadableError : unknown ;
0 commit comments