Files
callstack__agent-device/docs/adr/0005-ios-runner-interaction-lifecycle.md
Michał Pierzchała 3a02e514e1 refactor(ios): consolidate series batching onto the sequence runner command (#768)
* refactor(ios): consolidate series batching onto the sequence runner command

Closes #767

Routes every Apple multi-press variant (plain, double-tap, hold, jitter)
and swipe series through budget-chunked sequence requests, retiring the
daemon-side tapSeries and dragSeries senders:

- Add a doubleTap step kind to the sequence allowlist on both ends,
  mirroring the retired tapSeries doubleTapAt branch.
- The single doubleTap interactor sends a one-step sequence and parses
  the result, surfacing step failures as errors.
- Swipe series unroll ping-pong daemon-side into per-step endpoints;
  the runner's coordinate-drag path ignores durationMs exactly as the
  daemon-sent (non-synthesized) dragSeries did.
- Extract runIosSequenceChunks so press and swipe share the chunking,
  aggregation, and global step-index rebasing.
- Keep tapSeries/dragSeries runner handlers for wire compatibility with
  older daemons, annotated like interactionFrame; remove both from the
  preflight-skip allowlist (daemon never sends them) and update ADR
  0005 / protocol-optimizations docs.

This also closes the latent watchdog exposure where press --count N
--interval-ms M routed to tapSeries and executed all pauses inside one
30s-watchdog main-thread block with no chunking.

Behavior note: plain tap series now use the synthesized HID tap path on
iOS non-tv (with runner-side tapAt fallback), matching the individual
tap command instead of the retired tapSeries' XCUICoordinate taps.

https://claude.ai/code/session_01VokBZWESTDgcnbYwS4DkJo

* refactor(ios): drop dead series wire surface from the daemon

- Remove chunkRunnerSequenceSteps: superseded by the budget-aware
  chunker; no production callers remained.
- Remove tapSeries/dragSeries from the RunnerCommand union along with
  their orphaned fields (count, intervalMs, doubleTap, pauseMs,
  pattern) and protocol fixtures: this type is the send surface of the
  current daemon, which no longer sends either command. The Swift
  runner keeps serving both for wire compatibility with older daemons.
- Retarget the ready-mutation preflight test from tapSeries to
  sequence.

https://claude.ai/code/session_01VokBZWESTDgcnbYwS4DkJo

* refactor(ios): remove retired series and frame wire commands entirely

Drops the runner-side wire compatibility for tapSeries, dragSeries, and
interactionFrame now that no daemon path sends them (series fuse into
sequence since this branch; interactionFrame was fused into scroll in
#760):

- Swift: delete the three handler cases, performDragSeries, runSeries
  (no remaining callers), the CommandType enum cases, journal-retention
  and traits entries, and the Command fields (count, intervalMs,
  doubleTap, pauseMs, pattern) that existed only for them. The
  never-sent synthesized dragSeries branch goes with it.
- TS: drop interactionFrame from the RunnerCommand union and
  isReadOnlyRunnerCommand, and its protocol fixture.
- Update stale perf scenario labels referencing the retired commands.

Verified dead before removal: no dynamic command construction anywhere
(runner-command-recovery only echoes in-flight command ids), no
raw-string references in Swift, no docs references. Helpers shared with
live paths (synthesizedDragAt, doubleTapAt, keyboardAvoidingDragPoints,
sleepFor) all retain callers.

Compat: an old daemon paired with a runner built from these sources
gets a CommandType decode rejection; the source-fingerprint check
rebuilds a matching runner on the next session.

https://claude.ai/code/session_01VokBZWESTDgcnbYwS4DkJo

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-11 14:08:30 +02:00

5.9 KiB

ADR 0005: iOS Runner Interaction Lifecycle

Status

Accepted

Context

The iOS runner is a long-lived XCTest process with an HTTP command loop. A command can appear to complete at the daemon boundary while XCTest is already tearing down the test runner.

This was reproduced in the React Navigation playground with navigation-causing selector taps such as Navigate to Details and Back to home. The runner resolved the button and synthesized the tap, the app navigated, and then XCTest tried to re-resolve the original XCUIElement. Because the element had disappeared, xcodebuild recorded Failed to get matching snapshot and ended the test with ** TEST EXECUTE FAILED **. The daemon had already received a successful tap response, so the next read-only command inherited a stale cached runner.

Two older assumptions were wrong:

  • A recent successful runner response proves the runner is still healthy.
  • XCUIElement.tap() is the safest selector-tap primitive once a selector has resolved.
  • A cached XCUIApplication target remains safe after XCTest reports that the app's accessibility tree cannot be serialized.

Decision

Coordinate-first resolved element activation is the iOS/macOS selector-tap model. The runner still uses selectors or text queries to find the semantic XCUIElement, but when the element has a frame, activation taps the resolved center point instead of calling XCUIElement.tap(). tvOS remains focus/remote-driven because tvOS does not support normal coordinate input.

Ready runner sessions are probed with a short uptime preflight before command send. Read-only startup commands still skip that preflight because the first successful command is the readiness proof for a newly launched runner. Readiness probe commands skip preflight to avoid recursion.

The daemon may additionally skip the ready-session uptime preflight for an explicit allowlist of mutating interactions (tap, longPress, drag, swipe, scroll, sequence) when the same session produced a healthy mutating response — parsed ok and not carrying runnerFatal — for the same appBundleId within 5 seconds. This recency lives only on the RunnerSession object as lastHealthyMutation, so it dies with every invalidation/restart, and it is recorded only after the runnerFatal check, so sparse AX-fallback snapshots and runnerFatal payloads never refresh it. Snapshots and other read-only responses never count as a health signal. This narrow skip is permitted now because the future-work precondition below is met: coordinate-first activation removed the command-induced teardown trigger, and the lifecycle status journal plus the status-before- invalidate recovery is the teardown-surviving status surface that resolves any ambiguous post-send failure before invalidation. A transport failure after a skip clears the recency record and is marked with the skip context; connection-shaped failures (refused, reset, hung up) run status recovery instead of a blind replay, while timeout-shaped failures propagate with the skip context (the same classification preflighted sends use).

uptime is a direct runner listener probe. It is answered before command journaling, the serial command execution queue, app activation, and main-thread XCTest dispatch. It should measure only whether the runner is alive and accepting new HTTP requests.

Dead cached runner processes are invalidated without graceful shutdown. A process that already stopped cannot answer the shutdown request, so graceful cleanup only adds stale-listener delay.

When XCTest reports a root accessibility snapshot failure such as kAXErrorIllegalArgument, the runner treats the cached app target as suspect. Interactive snapshots fail closed to a truncated root-only payload instead of issuing more flat fallback queries against the same broken tree, and the cached XCUIApplication handle is cleared so the next command reacquires the target through the normal activation path.

The snapshot surface intentionally has two AX-failure shapes. Interactive fast snapshots return a truncated success payload with runnerFatal so agents can still see that AX state is unavailable and recover with a plain screenshot plus coordinate navigation. Raw or strict snapshot paths keep returning an error because those callers requested a faithful tree, not a lossy recovery payload.

Consequences

Navigation-causing selector taps no longer couple command success to XCTest's post-tap element bookkeeping. If the target disappears because navigation happened, the tap remains a normal successful interaction and the runner should stay alive.

If xcodebuild still exits for another reason, the next command detects the stale runner through process/liveness checks and avoids the old 15-second graceful-shutdown wait. The remaining latency is fresh xcodebuild runner startup, not a stale transport stall.

The daemon no longer models a generic "recent success" cache as a runner-health signal. A proven healthy mutating response for the same app — recorded only after the runnerFatal check and only for allowlisted interactions — is now a real end-to-end liveness proof (HTTP listener through to the app target), so a hot loop of allowlisted interactions skips the per-command uptime request while still re-earning each skip from another healthy mutation. The earlier unconditional uptime before every ready-session command remains the default for non-allowlisted commands and after any invalidation, stale record, app-bundle change, or absent record.

Apps with broken accessibility trees may still be impossible for XCTest to inspect deeply, but one failed snapshot no longer teaches the runner to keep using a suspect cached app target or to amplify the failure by walking every interactive element query.

Future optimization work should only reduce these preflights after the runner exposes status in a way that survives command-induced XCTest teardown and can prove the session is still serving new requests.