Skip to content

wait fails on the first retriable RUNNER_BUSY instead of polling out its budget #2496

Description

@thymikee

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

  1. packages/platform-apple/src/runner/runner-session.ts:920 builds the error with retriable: true and keeps RUNNER_BUSY in details.runnerErrorCode.
  2. 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.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions