fix(mobile): bound each input by its budget

Merged
TH
13d ago
+293-50

What and why

  • The mobile engine bounded each agent-device call only by operation.signal. The harness aborts that signal with the attempt, never at timeoutMs, so an input that outlived actionTimeout held the step until the test timeout.
  • The OperationContext.signal docs said it also aborts "when the operation budget is spent". It doesn't for locator operations. The web engine enforces timeoutMs itself (packages/web/src/operation-budget.ts). The doc comment now says so.
  • InputBudget.send (packages/mobile/src/support.ts) wraps each device command that sends input. That covers node actions, the screen swipe, point actions, typing, keys, back, and the keyboard dismissal with its gesture fallbacks.
    • No budget left: nothing is sent, and the result is OPERATION_TIMEOUT.
    • Sent but no answer in time: ACTION_MAY_HAVE_COMMITTED, which the runner never repeats. Web's inputCutOff does the same.
    • Little budget left: the input is still sent and gets 250 ms to answer.
    • A hold longer than the budget left: a long press, or a timed swipe, that holds at least as long as the budget left is refused before it is sent. It fails as NOT_ACTIONABLE, naming the hold and the time left.
    • Several commands in one input: a focus tap then a keystroke is sent one command at a time, so no keystroke follows a tap that used the budget up.
    • Abandoned commands: a timed-out command stays tracked as in flight until it settles, so the next attempt still waits for it.
  • Reads stay unbounded, as on main. That covers observe, locate, and the relocation before a tap. Bounding them broke this PR's first CI run: a locator poll's last iOS snapshot was cut off at the deadline and read as ACTION_FAILED instead of LOCATOR_NOT_FOUND.
  • docs/mobile.mdx › Troubleshooting says what an action that outlives its timeout does.
  • Hung reads still hold the step until the test timeout, as on main: #1099.
  • No runtime change to e2e, so mobile's peer floor stays.

Verified

Ran it locally: yes

  • Setup: iOS simulator (iPhone 18 Pro Max, iOS 27), a scratch project on the built packages, com.apple.Preferences, actionTimeout: 4_000.
  • Long press: getByTestId('com.apple.settings.general').longPress({ duration: 8_000 }).
mainthis branch
step passes after 8.7 s (agent-device longpress ok 8685ms)73279167: refused before sending (no longpress request): ACTION_FAILED: longPress holds for 8000 ms, but only 616 ms of its timeout are left; raise the timeout or shorten the hold

Before the hold refusal, f1befc86 sent it and failed at 4.0 s: ACTION_FAILED: longPress timed out before the device answered; its input may have been dispatched.

  • Missing locator: getByText('No such row').tap({ timeout: 3_000 }) reports LOCATOR_NOT_FOUND on the branch, which is the CI regression fixed.
  • Unit tests:
    • every input entry, hung, times out as ACTION_MAY_HAVE_COMMITTED;
    • a slow relocation that spends the budget sends no press;
    • a focus tap that answers past the budget sends no keystroke;
    • a timed-out tap keeps the next attempt waiting until it settles;
    • a long press longer than its timeout is refused unsent;
    • each of these fails when its mechanism is removed.
  • CI: ios deterministic and android deterministic (the mobile benchmark's deterministic suites) pass.
  • Not verified on a device: the agent-origin path. No mobile agent suite runs on a fork PR. Near a step deadline it behaves like web: a tap left with a small budget gets 250 ms to answer.
  • Review: five fresh-context review rounds, cubic, and okwasniewski. Every finding was fixed or answered.

Checklist

  • Changeset added (pnpm changeset) for any change to a published package, or the change is testbed, docs, or CI only.
  • Breaking change: title carries !, the changeset body names what breaks and what replaces it, and the deprecation warning landed a window earlier (stability policy in CONTRIBUTING.md).
  • Docs updated in the same PR: the docs/**/*.mdx page for the behavior, and skills/e2e/ if the skill describes it.
  • Contract change: emitted .d.ts reviewed, tests/types/sdk-types.ts updated; wire change edits the schema and both fixtures.
  • Engine contract change (e2e/engine): changesets for e2e, @e2e-dev/web, and @e2e-dev/mobile.

The e2e/engine change is the OperationContext.signal doc comment only. The behavior it describes is unchanged.

🤖 Generated with Claude Code