Repository navigation
wait fails on the first retriable RUNNER_BUSY instead of polling out its budget #2496
Description
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: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.- The only policy consumer of
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. - The wait poll loop has one error-tolerance channel:
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 --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-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:
- Whether every wait kind gets this or only the capture-polling kinds.
wait --absentalready passes a classification and would need its predicate composed, not replaced. - Whether a runner stall that persists for the whole budget should keep
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.
Activity
thymikee commentedon Sep 11, 2026
Duplicate of the work already in flight as #2493, which fixes this and more. Closing in favor of it.
#2493 carries the same wait-side fix (a poll whose failure its producer marked retriable is ridden out to the deadline and recorded as a
retriablepoll in the timeout evidence), and it also fixes two things this issue did not identify:RUNNER_BUSYwas not a heavy-screen watchdog trip in general.acceptDeepLinkConfirmationIfPresenthad a hard-coded Automation-lab readiness landmark, so off that route it always fell through to analert getprobe, and an XCTest alert query against a liveWKWebViewexceeds the runner's 30 s main-thread watchdog. That is what left the runner refusing later commands. fix(wait): poll through a retriable runner refusal instead of surrendering the budget #2493 gives each caller its own route landmark so the probe is not issued.toAppErrorCode, soRUNNER_BUSYreached callers as a wire code with noretriableflag at all. fix(wait): poll through a retriable runner refusal instead of surrendering the budget #2493 routes both paths through oneclassifyRunnerReportedError.My trace of the wait-side mechanism in this issue stands, but #2493's diagnosis supersedes it as the root cause.