Skip to content

fix(ios): never launch a not-running session app from an observation - #2852

Open
thymikee wants to merge 1 commit into
mainfrom
fix/ios-deep-link-confirmation-route
Open

thymikee wants to merge 1 commit into
mainfrom
fix/ios-deep-link-confirmation-route

Conversation

@thymikee

@thymikee thymikee commented Sep 23, 2026

Copy link
Copy Markdown
Member

Summary

Targets iOS smoke flake smoke:automation-input step 13 wait for Automation lab (12 failures 09-16..22, 1 on main).

open --relaunch --launch-url terminates the app; simctl openurl can hold the launch behind SpringBoard's "Open in …?" prompt. The next read hit the runner's foreground repair, and activate() on a not-running app is a bare launch without the URL (runner log: AGENT_DEVICE_RUNNER_ACTIVATE … state=1).

No runner read launches the session app. The refusal keys on the runner's own read trait, so user-level reads (snapshot, wait, is, get, a reading find) and a mutation's leading reads — a gesture's gestureViewport, the capture that resolves a selector click/fill — refuse a not-running session app with APP_NOT_RUNNING (COMMAND_FAILED, retriable so wait polls; the transport reads it as a definite answer and never resends). The first round's daemon-side request-app-intent plumbing and the observationOnly wire field are removed: the runner's command trait table is the single source. open, activate, and interactions that mutate without a leading read keep the foreground repair. Help: website/docs/docs/commands.md "Foreground repairs on iOS" and the physical-device help topic's lifecycle facts.

Validation

Tested 6d41dd483e (rebased onto b3b58caac1), iPhone 17 Simulator on iOS 26.2 (a purpose-built device, destroyed after the runs):

  • Runner XCTest: full iOS-lane batch 106/106, zero process restarts, and the smoke step's AGENT_DEVICE_RUNNER_OPTIONAL_PROBE_WAIT_COMPLETED assertion passes. Two batch failures on the first pass (testAbandonedTreeCaptureSkipsQuerySweepAndHonorsWarmupExemption, testAlertAcceptDoesNotActivateAReplacementWithASharedButton) passed alone under no load; the clean re-run was 106/106.
  • Planted reds: testObservationRefusesANotRunningSessionAppInsteadOfLaunchingIt and testInteractionLeadingReadRefusesToLaunchANotRunningSessionApp fail on the previous rule (both reads launched Settings: state runningForeground, error == nil) and pass on the read-trait rule; testNonReadCommandStillLaunchesANotRunningSessionApp pins that a mutation still launches.
  • Crash root cause: the first-round helper called execute(command:) off the main queue, which asserts .onQueue(.main) — the dispatchPrecondition SIGTRAP restarted the batch and is what silenced the smoke marker. The main-thread helper removes it (3/3 with no restart).
  • Not-installed bundle: XCUIApplication.state reads .notRunning (raw 1) and the runner refuses a snapshot over it with APP_NOT_RUNNING in ~6 ms. A live wait text … 20000 against a not-running session app polled the retriable refusal to the full 20 s deadline (28 typed refusals in runner.log) and exited COMMAND_FAILED / details.runnerErrorCode: "APP_NOT_RUNNING" / retriable: true.
  • TS: eager-closure budgets 688/688 (no new eager module — the daemon-side intent plumbing is gone), runner-recovery-wiring 23/23, daemon-command-registry 9/9; pnpm format:check, pnpm lint, pnpm typecheck clean.
  • pnpm check:xctest-selection: 106 PR-lane selections, 0 unreachable. pnpm check:affected --run on the pushed head: all runnable checks passed.
  • PR-head CI: see thread.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 5b9d54d. The refusal is keyed on the runner wire command (isReadOnlyCommand), not on the user command (RunnerTests+Lifecycle.swift). Some mutating commands start with a runner read: gesture calls gestureViewport first (src/daemon/interaction/internal/interaction-runtime.ts:165 -> packages/platform-apple/src/interactions.ts:176), and selector click/fill resolve through a runner snapshot on the runner-snapshot route. If the session app crashed or was terminated, these now fail with a retriable APP_NOT_RUNNING instead of repairing the foreground. The CHANGELOG line "Interactions keep the foreground repair" does not match that, and the new non-read test only covers activate.

Which rule do you want? Either no runner read may launch the app, including reads before a mutation; then the CHANGELOG and help should say selector and gesture interactions also refuse, and a gestureViewport test should cover it. Or only user-level reads refuse; then the launch intent should come from the registry's read-only trait per request, and gestureViewport and interaction-resolution snapshots need to allow a launch. The first looks smaller and matches the PR's reasoning. Is that right?

Also worth checking: for a bundle that is not installed, does XCUIApplication.state read .notRunning? If so, does wait poll the retriable APP_NOT_RUNNING until its deadline instead of failing fast?

The 5 reported checks are green. The PR has a merge conflict with main in CHANGELOG.md; please rebase after picking the rule.

@thymikee
thymikee force-pushed the fix/ios-deep-link-confirmation-route branch from 5b9d54d to 4e93713 Compare September 23, 2026 19:13
@thymikee thymikee changed the title fix(ios): never launch a not-running session app from a read fix(ios): never launch a not-running session app from an observation Sep 23, 2026
@github-actions

github-actions Bot commented Sep 23, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-2852/

Built to branch gh-pages at 2026-09-24 06:16 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Sep 23, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.79 MB 4.79 MB +1.6 kB
Package (unpacked) 4.79 MB 4.79 MB +1.6 kB
Package (download) 1.43 MB 1.43 MB +477 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 25.4 ms 25.0 ms -0.4 ms
CLI --help 71.8 ms 70.5 ms -1.2 ms

thymikee added a commit that referenced this pull request Sep 23, 2026
The refusal now keys on the runner's own read trait, not on a host-stamped per-request mark:
isReadOnlyCommand covers findText, readText, snapshot, gestureViewport, and alert get, so a
mutation's leading read -- the viewport read a gesture starts with, the capture that resolves a
selector click/fill -- refuses with APP_NOT_RUNNING exactly like a user-level read. A bare
activate over a launch SpringBoard holds behind its "Open in ...?" confirmation is a launch
without the URL, whatever command asked for the read.

The daemon-side request-app-intent plumbing and the observationOnly wire field are removed:
the runner's command trait table is the single source of the rule. The refusal stays retriable
for a wait poll while the transport reads it as a definite answer; open, activate, and
interactions that mutate without a leading read keep the foreground repair.

This adopts the rule picked in the #2852 review (option 1) and says so in the CHANGELOG, the
foreground-repairs docs section, and the physical-device help topic's lifecycle facts.
@thymikee
thymikee force-pushed the fix/ios-deep-link-confirmation-route branch from 4e93713 to 6d41dd4 Compare September 23, 2026 21:08
@thymikee

Copy link
Copy Markdown
Member Author

Rule picked: option 1 — no runner read may launch the app, including reads before a mutation. Pushed as 6d41dd483e (rebased onto b3b58caac1; the lane commit folded into the rule commit).

Your reading was right about the mechanism at the reviewed head; by the time you commented, the tip had moved to the option-2 shape (a host-stamped observationOnly per request, from the registry trait). This round reverses that: the refusal is isReadOnlyCommand(command) && state == .notRunning in notRunningReadResponse (RunnerTests+Lifecycle.swift), keyed on the runner's trait table (findText, readText, snapshot, gestureViewport, alert get). The observationOnly wire field and the daemon-side withRequestAppIntent/resolveRequestAppIntent plumbing are deleted — the trait table is the single source, so nothing else can drift from it.

  • Where the rule is stated: CHANGELOG ("no runner read launches a session app that is not running anymore … an interaction's leading reads — the viewport read a gesture starts with, the capture that resolves a selector click/fill — now refuse"); the commands.md "Foreground repairs on iOS" bullet, now "No read launches a stopped app", naming both leading reads; and the versioned help — a new sentence in the physical-device topic's "Runner and daemon lifecycle (applies to simulators too)".
  • Tests: testInteractionLeadingReadStillLaunchesANotRunningSessionApp pinned the opposite rule, so it is inverted and renamed to testInteractionLeadingReadRefusesToLaunchANotRunningSessionApp — it drives a bare gestureViewport at terminated com.apple.Preferences and asserts APP_NOT_RUNNING + state == .notRunning. The planted red was run against the previous rule: both refusal tests failed there (error == nil, state runningForeground — the reads launched) and pass now. testNonReadCommandStillLaunchesANotRunningSessionApp keeps the positive pair. Selector resolution rides the same snapshot wire trait through the same dispatch site. Lane names updated in .github/workflows/ios.yml.
  • The CI smoke-step failure had a different cause than a terminate() of a never-run app: the first-round helper called execute(command:) off the main queue, and execute asserts dispatchPrecondition(.onQueue(.main)) (RunnerTests+CommandExecution.swift:840). The SIGTRAP restarted the test process mid-batch, so the later TextInputProbe tests never emitted AGENT_DEVICE_RUNNER_OPTIONAL_PROBE_WAIT_COMPLETED. Reproduced the abort on iOS 26.2 (same runtime as the lane), then fixed by calling execute from the test's main thread — full batch now 106/106, 0 restarts, marker assertion green. A bare terminate() on a never-run target is a no-op on 26.2, so no guard around it.
  • Not-installed bundle measurement (iPhone 17 / iOS 26.2 simulator, destroyed after): XCUIApplication(bundleIdentifier: "com.example.not-installed-2852").state reads .notRunning (raw 1) and the snapshot is refused APP_NOT_RUNNING in ~6 ms. For wait: end-to-end wait text "General" 20000 against a not-running session app polled the retriable refusal to the full 20 s deadline — 28 AGENT_DEVICE_RUNNER_READ_TARGET_NOT_RUNNING lines (~1 Hz, findText/snapshot) in runner.log — and exited with COMMAND_FAILED, details.runnerErrorCode: "APP_NOT_RUNNING", retriable: true, exit 1. (System Settings cannot be uninstalled on a Simulator, so the not-installed state is measured at the runner and the poll loop against the same .notRunning state it keys on — the refusal is state-only, so the two paths are the same code.)

Still outstanding: CI on 6d41dd483e — coverage/provider/device lanes report here if anything goes red.

@thymikee

Copy link
Copy Markdown
Member Author

This is ready to merge at 6d41dd4. The guard now refuses a leading read before it ever reaches activateTarget, closing the gap from the earlier review (5b9d54d, #2852 (comment)). I know of no conflicts. All 20 checks pass on this commit, and the new XCTest cases run in the ios.yml -only-testing lane. I did not reproduce the author's live measurements (the 20 s wait-poll deadline, the not-installed-bundle .notRunning read, and the planted-red check against the previous rule) and took them from the author's comment, and I did not trace whether iOS scroll or coordinate fill send a leading runner read. Not blocking: CHANGELOG.md, src/commands/schema/cli-help.ts, and website/docs/docs/commands.md still list swipe as an interaction that launches a stopped app, but on this head swipe resolves through resolveGestureViewport (https://github.com/callstack/agent-device/blob/6d41dd4/src/commands/interaction/runtime/gestures.ts#L77) into a gestureViewport read that notRunningReadResponse already refuses, so a swipe on a not-running session app now returns APP_NOT_RUNNING instead of launching it, and separately, is querySelector (https://github.com/callstack/agent-device/blob/6d41dd4/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests%2BModels.swift#L113) meant to carry readOnly: .never given it's a pure read that still reaches activateTarget through the Maestro direct-selector click and the offscreen-target probe — you can take or leave both for this PR, but the underlying rule worth stating somewhere is that a command belongs in the "still launches a stopped app" list only if its first runner call for the session app has readOnly: .never, so each named command needs its first call traced against that rule rather than assumed from its read/write shape. Before this merges, the swipe (and scroll) claim in CHANGELOG.md, cli-help.ts, and commands.md should be corrected to match the gestureViewport refusal.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 23, 2026
thymikee added a commit that referenced this pull request Sep 24, 2026
The refusal now keys on the runner's own read trait, not on a host-stamped per-request mark:
isReadOnlyCommand covers findText, readText, snapshot, gestureViewport, and alert get, so a
mutation's leading read -- the viewport read a gesture starts with, the capture that resolves a
selector click/fill -- refuses with APP_NOT_RUNNING exactly like a user-level read. A bare
activate over a launch SpringBoard holds behind its "Open in ...?" confirmation is a launch
without the URL, whatever command asked for the read.

The daemon-side request-app-intent plumbing and the observationOnly wire field are removed:
the runner's command trait table is the single source of the rule. The refusal stays retriable
for a wait poll while the transport reads it as a definite answer; open, activate, and
interactions that mutate without a leading read keep the foreground repair.

This adopts the rule picked in the #2852 review (option 1) and says so in the CHANGELOG, the
foreground-repairs docs section, and the physical-device help topic's lifecycle facts.
@thymikee
thymikee force-pushed the fix/ios-deep-link-confirmation-route branch from 6d41dd4 to a06b951 Compare September 24, 2026 05:59
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main at a06b951, and squashed 20f157d/3bed24a/6d41dd4 into the one final-rule commit; the logical patch is unchanged. Main's #2855 split RunnerTests+CommandExecution.swift, so the notRunningReadResponse guard now sits at the same point in RunnerTests+CommandDispatch.swift. #2854's LifecycleTests block is kept next to the new tests. On a fresh iPhone 17 Pro / iOS 26.2 unit-test build, the 3 new tests and the neighbouring lifecycle test ran 4/4. pnpm check:affected --run passed on a06b951.

The refusal now keys on the runner's own read trait, not on a host-stamped per-request mark:
isReadOnlyCommand covers findText, readText, snapshot, gestureViewport, and alert get, so a
mutation's leading read -- the viewport read a gesture starts with, the capture that resolves a
selector click/fill -- refuses with APP_NOT_RUNNING exactly like a user-level read. A bare
activate over a launch SpringBoard holds behind its "Open in ...?" confirmation is a launch
without the URL, whatever command asked for the read.

The daemon-side request-app-intent plumbing and the observationOnly wire field are removed:
the runner's command trait table is the single source of the rule. The refusal stays retriable
for a wait poll while the transport reads it as a definite answer; open, activate, and
interactions that mutate without a leading read keep the foreground repair.

This adopts the rule picked in the #2852 review (option 1) and says so in the CHANGELOG, the
foreground-repairs docs section, and the physical-device help topic's lifecycle facts.
@thymikee
thymikee force-pushed the fix/ios-deep-link-confirmation-route branch from a06b951 to b5a510d Compare September 24, 2026 06:16
@thymikee

Copy link
Copy Markdown
Member Author

Simplified at b5a510d after a thermo-nuclear quality pass. Behavior is unchanged: +174/−5 → +156/−15.

  • runner-contract.ts: one DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES map carries the poll-retriable flag, replacing the second RETRIABLE_RUNNER_ERROR_CODES set. The constant doc owns the rationale and names the #if os(iOS) scope (macOS, tvOS and visionOS runners keep the activate repair).
  • The Swift docstring and the classification ordering prose are cut to one line each.
  • The two read tests are one loop over snapshot and gestureViewport, and the ios.yml lane list follows. The review-history narration comment is gone.

Not done here: the review also proposed narrowing flagged_retriable to RUNNER_BUSY so the app_not_running row could go. That would also stop resends of startup-phase retriable: true failures (runner-cache-metadata.ts:115,320) and provider errors, which is a separate behavior change. I filed it as a follow-up.

Validation: pnpm check:affected --run passed. The two lifecycle tests ran 2/2 on a fresh iPhone 17 Pro / iOS 26.2 unit-test build, and the runner-contract tests passed 9/9.

This branch has not been deployed

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

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant