wait holds a 20 s budget but cannot spend any of it on a retriable runner stall. The first RUNNER_BUSY from a capture ends the wait immediately, with zero polls recorded, even though the error declares itself retriable and its own hint tells the caller to wait and retry.
Evidence
iOS smoke lane, job 103282380298, scenario smoke:webview-remote-content:
command: agent-device wait text Jump to form 20000 ...
"code": "RUNNER_BUSY",
"message": "The iOS runner is still finishing a previous command that exceeded its
execution watchdog (usually an accessibility capture on a heavy or
animating screen)."
"hint": "Wait a few seconds and retry. ..."
details: { command: "snapshot", lifecycleState: "failed",
recovery: "runner_reported_failure" }
The response carries none of the wait evidence fields (reason, timeoutMs, polls, captures, waitedMs, readableCaptures). That absence is the proof: the failure did not come from the wait's own timeout path at all. The capture error escaped the poll loop and became the command error.
Mechanism
packages/platform-apple/src/runner/runner-session.ts:920 builds the error with retriable: true and keeps RUNNER_BUSY in details.runnerErrorCode.
retriable is a wire classification only. src/daemon/request-router.ts:601 and src/daemon/request-finalization.ts:53 copy it outward to the client. No host-side caller reads it to retry.
- The only policy consumer of
details.runnerErrorCode is packages/platform-apple/src/alert.ts:149, for ALERT_NOT_FOUND. RUNNER_BUSY is named in that file and in packages/contracts/src/alert-contract.ts only as prose precedent.
- The wait poll loop has one error-tolerance channel:
createUnreadablePollTracker in src/commands/interaction/runtime/wait-polling.ts:248, whose default predicate isUnreadableCaptureContentError (packages/contracts/src/android-snapshot-quality.ts:30) inspects details.androidSnapshotHelperFailureReason and nothing else. wait text passes no classification (src/commands/interaction/runtime/wait-text.ts:19), so an iOS runner error is never tolerated.
wait --absent is the one caller that supplies a classification (wait-absent.ts:41); the rest ride the Android-only default.
Why it matters beyond the lane
This is the agent-facing contract, not just CI. A heavy or animating screen trips the runner's execution watchdog, and the very next capture is contended. Today wait converts a transient, self-declared-retriable contention into a hard command failure, so the agent must re-issue the whole wait from scratch and pay the cold cost again. A wait that rides out the contention inside its existing budget is strictly better and costs no extra wall-clock on the happy path.
It is also the standing cause of row 1 in #2491 (wait for the WebView page to expose its link), which has rotated back on main and on PR runs.
Proposed shape
Admit a retriable runner stall into the existing unreadable-poll channel rather than inventing a second one:
- Classify it at the platform boundary as a typed reason, the way the Android helper already publishes
androidSnapshotHelperFailureReason, so readers consume a decision instead of sniffing a code. details.runnerErrorCode === 'RUNNER_BUSY' already exists and is the natural carrier.
- Widen the poll tolerance so the wait family treats it like an unreadable content verdict: record the poll as
unreadable, sleep the interval, poll again while budget remains.
- On exhaustion the existing
capture-stalled path already produces the right outcome, with readableCaptures: 0 and the full poll timeline, which is far better evidence than today's bare transport error.
Two things to decide during implementation:
- Whether every wait kind gets this or only the capture-polling kinds.
wait --absent already passes a classification and would need its predicate composed, not replaced.
- Whether a runner stall that persists for the whole budget should keep
capture-stalled or earn its own reason, the way runner-restart-exhausted did. runner-restart-exhausted is the precedent for a distinct reason when the cause is a known runner lifecycle event.
Out of scope
Changing the runner's execution watchdog, or making the WebView capture cheaper. Those are separate; this issue is only about the host spending a budget it already has.
waitholds a 20 s budget but cannot spend any of it on a retriable runner stall. The firstRUNNER_BUSYfrom a capture ends the wait immediately, with zero polls recorded, even though the error declares itself retriable and its own hint tells the caller to wait and retry.Evidence
iOS smoke lane, job
103282380298, scenariosmoke:webview-remote-content:The response carries none of the wait evidence fields (
reason,timeoutMs,polls,captures,waitedMs,readableCaptures). That absence is the proof: the failure did not come from the wait's own timeout path at all. The capture error escaped the poll loop and became the command error.Mechanism
packages/platform-apple/src/runner/runner-session.ts:920builds the error withretriable: trueand keepsRUNNER_BUSYindetails.runnerErrorCode.retriableis a wire classification only.src/daemon/request-router.ts:601andsrc/daemon/request-finalization.ts:53copy it outward to the client. No host-side caller reads it to retry.details.runnerErrorCodeispackages/platform-apple/src/alert.ts:149, forALERT_NOT_FOUND.RUNNER_BUSYis named in that file and inpackages/contracts/src/alert-contract.tsonly as prose precedent.createUnreadablePollTrackerinsrc/commands/interaction/runtime/wait-polling.ts:248, whose default predicateisUnreadableCaptureContentError(packages/contracts/src/android-snapshot-quality.ts:30) inspectsdetails.androidSnapshotHelperFailureReasonand nothing else.wait textpasses no classification (src/commands/interaction/runtime/wait-text.ts:19), so an iOS runner error is never tolerated.wait --absentis the one caller that supplies a classification (wait-absent.ts:41); the rest ride the Android-only default.Why it matters beyond the lane
This is the agent-facing contract, not just CI. A heavy or animating screen trips the runner's execution watchdog, and the very next capture is contended. Today
waitconverts a transient, self-declared-retriable contention into a hard command failure, so the agent must re-issue the wholewaitfrom scratch and pay the cold cost again. A wait that rides out the contention inside its existing budget is strictly better and costs no extra wall-clock on the happy path.It is also the standing cause of row 1 in #2491 (
wait for the WebView page to expose its link), which has rotated back on main and on PR runs.Proposed shape
Admit a retriable runner stall into the existing unreadable-poll channel rather than inventing a second one:
androidSnapshotHelperFailureReason, so readers consume a decision instead of sniffing a code.details.runnerErrorCode === 'RUNNER_BUSY'already exists and is the natural carrier.unreadable, sleep the interval, poll again while budget remains.capture-stalledpath already produces the right outcome, withreadableCaptures: 0and the full poll timeline, which is far better evidence than today's bare transport error.Two things to decide during implementation:
wait --absentalready passes a classification and would need its predicate composed, not replaced.capture-stalledor earn its own reason, the wayrunner-restart-exhausteddid.runner-restart-exhaustedis the precedent for a distinct reason when the cause is a known runner lifecycle event.Out of scope
Changing the runner's execution watchdog, or making the WebView capture cheaper. Those are separate; this issue is only about the host spending a budget it already has.