Files
callstack__agent-device/docs/ios-runner-protocol-optimizations.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

8.1 KiB

iOS runner protocol optimization plan

Issue #656 is now split into protocol infrastructure plus follow-up optimizations. The lifecycle protocol makes commands identifiable, but the performance wins come from changing when the daemon uses uptime, retries, invalidates sessions, and asks the runner for lifecycle status.

Work slices

1. Status-before-invalidate recovery

Status: in progress on codex/ios-runner-status-recovery.

Goal: when a command has been sent and the HTTP response is lost, ask the runner for status(statusCommandId) before invalidating the session or surfacing an ambiguous transport failure.

Acceptance criteria:

  • Post-send retryable transport failures issue one bounded status probe with the original commandId before session invalidation.
  • completed with retained small response JSON returns the recovered command result without invalidating or resending the command.
  • failed returns the runner failure code/message/hint instead of a generic transport failure.
  • notAccepted, status timeout, or status transport failure preserves the existing invalidation behavior.
  • Read-only commands whose response was not retained keep the existing retry behavior.
  • Status recovery probes are short-budget and do not consume the full command timeout.

iOS simulator validation:

  • Unit: pnpm exec vitest run src/platforms/ios/__tests__/runner-command-retry.test.ts.
  • Unit bundle: pnpm exec vitest run src/platforms/ios/__tests__/runner-client.test.ts src/platforms/ios/__tests__/runner-session.test.ts src/platforms/ios/__tests__/runner-command-retry.test.ts src/platforms/ios/__tests__/runner-provider.test.ts.
  • Build: pnpm build:xcuitest.
  • Manual sim smoke after build:
    • pnpm build
    • pnpm clean:daemon
    • run a simple iOS simulator session against Settings with open, snapshot -i, one selector interaction, and close.
    • confirm there is no visible behavior change and diagnostics show no unexpected session invalidation.

2. Adaptive uptime preflight policy

Status: implemented with guardrails (see ADR 0005). The earlier blanket "recent success" cache was shipped and then reverted in #702 because XCTest could return a successful tap response and then fail the runner while re-resolving a navigation-disappeared element, and because sparse AX-fallback snapshots were cached as healthy state. #702's coordinate-first activation removed that teardown trigger, so the skip is reintroduced as a structurally narrower "healthy mutation recency" signal.

Goal: skip the per-command uptime for hot allowlisted interaction loops only when a proven healthy mutating response makes the runner's liveness already known, while every uncertain path keeps preflighting.

Acceptance criteria (as shipped):

  • First-command/startup, no-record, stale-record, app-activation-uncertain, and non-allowlisted (conservative) commands still preflight; readiness probes and read-only startup commands keep their existing skips.
  • Recency is derived only from healthy (parsed ok, non-runnerFatal) responses of an explicit mutating allowlist (tap, longPress, drag, swipe, scroll, sequence) for the same appBundleId, within a 5s freshness window, and lives only on the session object so it dies with every invalidation/restart. Snapshots and read-only responses never refresh it.
  • A transport failure after a skipped preflight clears the recency record and marks the error with the skip context (runnerReadinessPreflightSkipped, distinct from the restart predicate's runnerReadinessPreflightFailed). Connection-shaped failures run status recovery before invalidation — never a replay; timeout-shaped failures propagate with the skip context, matching the existing classification for preflighted sends.
  • Diagnostics expose whether a command used, skipped, or recovered from a readiness preflight, including command type, skip reason, and recency age.
  • Measured threshold: 1 runner request per hot allowlisted command after the first, with no increase in invalidation or failure rate.

iOS simulator validation:

  • Start a fresh simulator session and run one interaction: verify the first mutating command still preflights.
  • Run a hot loop of repeated selector interactions against the same visible control: verify the runner remains healthy and diagnostics explain any readiness probe that was skipped.
  • Compare median command latency for a hot interaction loop before and after the change. A useful threshold is at least one fewer runner request per hot command and no increase in failure rate.

3. Status-visible transport path

Goal: make accepted and started states practically observable while a command is still running. The Swift journal already records these states, but the runner currently serializes connection handling, so a concurrent status request can be blocked behind the command it is querying.

Acceptance criteria:

  • status can be answered while another runner command is waiting on main-thread XCTest work.
  • The status path remains journal-only and does not touch app activation, XCTest dispatch, or command retry logic.
  • Long-running command status can report accepted or started before the command reaches a terminal state.
  • Existing command execution remains serial where mutation ordering matters.

iOS simulator validation:

  • Run a deliberately long runner command in one request.
  • While it is in flight, query status(statusCommandId) from another request.
  • Verify status returns before the long command completes and reports accepted or started.
  • Verify normal command ordering is unchanged for back-to-back mutating commands.

4. Session invalidation reduction

Goal: avoid tearing down otherwise healthy runner sessions when lifecycle status proves the command completed or failed cleanly.

Acceptance criteria:

  • Completed/failed lifecycle status suppresses invalidation for ambiguous post-send transport errors when the runner remains reachable.
  • Unknown status states still invalidate to preserve current safety.
  • Diagnostics record why invalidation was skipped or retained.
  • No command is replayed after an observed mutating accepted, started, completed, or failed state.

iOS simulator validation:

  • Inject or simulate a lost response after a command completes.
  • Verify status recovery prevents runner restart.
  • Run the next command in the same session and verify it succeeds without re-launching xcodebuild.

5. Response retention tuning

Goal: retain enough small command results for useful recovery without making the runner retain large snapshots or binary-like payloads.

Acceptance criteria:

  • Small scalar responses can be recovered from lifecycleResponseJson.
  • Snapshot node trees and screenshots are not serialized or retained in the journal.
  • The journal memory cap remains bounded by entry count and response JSON size.
  • Retention policy is documented in tests or runner fixtures so future commands do not accidentally store large payloads.

iOS simulator validation:

  • Run small-result commands and verify status can recover retained JSON.
  • Run snapshot-heavy commands and verify status reports terminal state without retained response JSON.
  • Confirm the runner remains responsive after repeated snapshots.

Suggested ordering

  1. Land status-before-invalidate recovery first. It is the safety net needed before reducing defensive preflights.
  2. Add diagnostics/metrics for preflight use, skipped preflights, status recovery, and invalidation reason. This can happen alongside slice 1 or 2.
  3. Reduce uptime for hot interaction loops with a conservative command allowlist.
  4. Make the status transport path observable during long-running commands.
  5. Broaden the preflight policy only after simulator measurements show stable behavior.

Side-by-side work

  • Status recovery and diagnostics can be developed together or separately.
  • Transport status visibility can proceed independently once the protocol is on main.
  • Adaptive uptime should wait for status recovery, because it relies on the same recovery path for ambiguous post-send failures.
  • Response retention tuning can proceed independently as long as it preserves the current caps.