Skip to content

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

Merged
thymikee merged 1 commit into
mainfrom
fix/ios-deep-link-confirmation-route
Sep 24, 2026
Merged

thymikee merged 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
Preview removed because the pull request was closed.
2026-09-24 07:06 UTC

@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.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at b5a510d. The squashed and rebased commit keeps the reviewed behavior from 6d41dd4. The Swift guard in RunnerTests+CommandDispatch.swift and the host's APP_NOT_RUNNING classification agree, so an observation does not launch a session app that is not running. I found no blocking issues.

I checked this by reading the diff. I did not run the XCTest lane or the Node tests. The live run from the 6d41dd4 review still covers the device behavior, because the Swift guard did not change.

Smoke Tests is still running and has not failed. This diff changes the iOS runner read dispatch, so please check that job before merge.

@thymikee
thymikee merged commit faa69ff into main Sep 24, 2026
20 checks passed
@thymikee
thymikee deleted the fix/ios-deep-link-confirmation-route branch September 24, 2026 07:06
thymikee added a commit that referenced this pull request Sep 24, 2026
CommandTraits.readOnly was documented as retry eligibility and consumed as five
decisions, so opting a command out of one silently opted it out of the rest.
querySelector is deliberately not retried, and as a side effect it stopped being
refused while its app was stopped: it bare-launched the app, which #2852 forbids
for a runner read.

Replace readOnly with the facts each decision actually asks for — retryOnSessionLoss,
launchPolicy (noApp | existingApp | mayLaunch), convertsRecordedFailure, and
clearsRememberedTextEntryTap — and replace isLifecycle, which served both the
activation bypass and the recorded-failure exemption. Payload-dependent facts resolve
in one exhaustive switch against Command, so no consumer re-derives the payload rule
and CommandTraits.ReadOnly.conditional is gone. A command hosted by the surface that
already has focus keeps the route its platform proved: the skip is iOS-only, so macOS
and tvOS still activate, and only iOS answers a stopped-app read with APP_NOT_RUNNING.

querySelector now refuses rather than launching, and stays non-retried.

Co-Authored-By: opencode
thymikee added a commit that referenced this pull request Sep 24, 2026
CommandTraits.readOnly was documented as retry eligibility and consumed as five
decisions, so opting a command out of one silently opted it out of the rest.
querySelector is deliberately not retried, and as a side effect it stopped being
refused while its app was stopped: it bare-launched the app, which #2852 forbids
for a runner read.

Replace readOnly with the facts each decision actually asks for — retryOnSessionLoss,
launchPolicy (noApp | existingApp | mayLaunch), convertsRecordedFailure, and
clearsRememberedTextEntryTap — and replace isLifecycle, which served both the
activation bypass and the recorded-failure exemption. Payload-dependent facts resolve
in one exhaustive switch against Command, so no consumer re-derives the payload rule
and CommandTraits.ReadOnly.conditional is gone. A command hosted by the surface that
already has focus keeps the route its platform proved: the skip is iOS-only, so macOS
and tvOS still activate, and only iOS answers a stopped-app read with APP_NOT_RUNNING.

querySelector now refuses rather than launching, and stays non-retried.

Co-Authored-By: opencode
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