Skip to content

Commit eff45cd

Browse files
Lykhoydaclaude
andauthored
feat(#186): Phase 6 — foreign-flow arbitration + canonical Maestro surface (#276)
* docs(plan): #186 Phase 6 TDD plan — foreign-flow arbitration + canonical maestro surface Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan): #186 Phase 6 — amendments from multi-LLM plan review - BLOCKER: teardown grace (FOREIGN_GRACE_MS) — our own dying maestro driver matches the detector for seconds after lease release; cache-busting can't fix it, an arbiter-side lastFlowReleasedAt window does - ps axww (mid-path udid truncation = production false negatives) - RN_IOS_FOREIGN_GUARD knob (WARN stays as deprecated alias, loudly documented) - udid-gated in-flight dedup + screenshot lastActive clarification Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(#186): ForeignFlowGate — TTL-cached fail-open foreign-maestro detection (+ps -ww) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(#186): BUSY_FOREIGN_FLOW — foreign maestro session refuses L2/L3 fast at the arbiter Includes the plan-review teardown grace (FOREIGN_GRACE_MS): our own dying maestro driver matches the detector for seconds after lease release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(#186): screenshot keeps its simctl fallback during a foreign flow Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#201): snapshot the .app outside the device container before clearState Live-gate finding: the resolver returned the INSTALLED container path as --app-file, but clearState uninstalls the app and deletes that container before maestro-runner reinstalls from it — 'No such file or directory' mid-flow, app left uninstalled. The container resolution is now snapshotted to a temp dir (APFS clonefile cp -Rc, plain copy fallback) that survives the uninstall; DerivedData stays the fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(#186): canonical maestro surface + BUSY_FOREIGN_FLOW coexistence rule Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(#186): rebuilt dist + regenerated tool docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#186): bounded snapshot dir + stale knob doc line (PR review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 4ce0ff4 commit eff45cd

24 files changed

Lines changed: 1592 additions & 26 deletions

.changeset/phase6-foreign-flows.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"rn-dev-agent-cdp": minor
3+
"rn-dev-agent-plugin": minor
4+
---
5+
6+
#202 Phase 6 / #186 — foreign Maestro sessions become arbiter refusals; plugin maestro_run is the canonical surface.
7+
8+
While a foreign Maestro/XCUITest session drives the target simulator (UDID-scoped detection, 5 s TTL, fail-open), local `device_*` and flow tools refuse fast with `BUSY_FOREIGN_FLOW` (~50 ms measured) — pointing at the safe L1 reads — instead of colliding into the ~44 s runner-leak cascade. L1 introspection stays free; `device_screenshot` serves pixels via its simctl fallback; a ~10 s teardown grace after the plugin's own flows prevents self-false-positives while WDA dies. The two historical reasons to leave the plugin surface are live-gate-verified closed and #201 is closed — including a new fix: the clearState `--app-file` resolution is snapshotted outside the device container (the installed-container path used to be deleted by clearState itself before the reinstall could read it). `RN_IOS_FOREIGN_GUARD=0` disables both the warning and the refusal (`RN_IOS_FOREIGN_WARN=0` remains a deprecated alias). The foreign-runner `ps` scan now uses `-ww` (command-column truncation could silently drop the UDID → false negatives).

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ Repo-local troubleshooting memory (replaces the Experience Engine):
107107
- **`RnFastRunner` / `RnFastRunnerUITests-Runner` icons appear on the simulator** → Expected, not clutter. iOS device control is an XCUITest rig (D1219), so running it installs two apps: `RnFastRunner` (the minimal host app, bundle `dev.lykhoyda.rndevagent.fastrunner`) and `RnFastRunnerUITests-Runner` (the XCUITest harness — same pattern as WebDriverAgent's `WebDriverAgentRunner`). The Runner hosts the `POST /command` HTTP server on port 22088 and drives YOUR app via `XCUIApplication(bundleIdentifier:)` — it never drives itself. It stays installed/running on purpose so subsequent `device_*` calls are fast; leave it. (Contrast the legacy `AgentDeviceRunner` above, which IS unwanted.)
108108
- **"Disconnected due to opening a second DevTools window" / React Native DevTools keeps getting kicked** → RN allows exactly one debugger frontend per app, and the bridge auto-reconnects by default (agent-first). To let the visual DevTools hold the seat, set `RN_CDP_AUTOCONNECT=0` (or `.rn-agent/config.json``{ "cdp": { "autoConnect": false } }`). The bridge then reconnects only when a CDP tool actually runs, and yields again once you reopen DevTools. Note: **any** CDP tool call — including `cdp_status` — reclaims the seat while it runs; passive mode only stops *background* re-grabs. Check the resolved mode in `cdp_status``autoConnect`.
109109
- **MCP server died when Metro was restarted (all tools gone until session restart)** → Fixed since #202 Phase 5 (#264): the stdio supervisor holds no network sockets, so port-based kills (`lsof -ti tcp:8081 | xargs kill -9`) only take the worker, which respawns automatically (`cdp_status``bridge.workerRestarts`). If tools error with "worker is crash-looping", check the bridge log (`LOG_LEVEL=info` writes it) and restart the session. `RN_BRIDGE_SUPERVISOR=0` opts back into the legacy single-process bridge.
110+
- **`BUSY_FOREIGN_FLOW` on device_*/maestro_run** → A foreign Maestro/XCUITest session (e.g. standalone maestro-mcp) is driving the same simulator. By design (#186): wait for it to finish (the guard clears within ~5 s of the foreign run ending), use L1 reads (`cdp_component_tree`, `cdp_store_state`) and `device_screenshot` meanwhile, or disable the guard with `RN_IOS_FOREIGN_GUARD=0` (`RN_IOS_FOREIGN_WARN=0` is a deprecated alias with the same effect). The first tap within ~10 s after your OWN `maestro_run` is exempt by design (WDA teardown grace).
110111
- **"No booted simulator"** → Open Simulator.app or boot one via Xcode
111112
- **iOS 26.x beta issues** → Use iOS 18 stable runtime (Xcode > Settings > Platforms)
112113
- **Node.js odd version (v25)** → Switch to Node 22 LTS: `nvm install 22 && nvm use 22`
@@ -157,7 +158,7 @@ One mechanism per capability tier. The device-session honors this contract (the
157158
| **L2 INTERACTION** | iOS `RnFastRunner` / Android `agent-device`; `cdp_interact` | primitive taps / types / scrolls | **shared** | re-attach, don't evict (Tier-0 reacquire + CDP re-pin, #188) |
158159
| **L3 FLOW-REPLAY** | `maestro-runner` (Go + WDA) | whole-`.yaml` E2E flows | **exclusive** | owns the device for the flow's duration |
159160

160-
**Coexistence rule:** L1 reads never conflict with a foreign runner; L2 re-attaches rather than evicts; L3 owns the device. On `device_snapshot action=open`, if a foreign maestro session is detected (UDID-scoped) AND no local flow lease is held, the open result carries an informational `meta.foreignRunner` + `FOREIGN_RUNNER_ACTIVE` warning (`runners/external-runner-detect.ts`; opt out with `RN_IOS_FOREIGN_WARN=0`). See `docs-site` → "Using rn-dev-agent with maestro-mcp".
161+
**Coexistence rule:** L1 reads never conflict with a foreign runner; L2 re-attaches rather than evicts; L3 owns the device. Since #202 Phase 6 (#186), a detected foreign Maestro session is an **arbiter input**, not just a warning: while it is live (UDID-scoped, 5 s-TTL `ps axww` scan via `lifecycle/foreign-flow-gate.ts`, fail-open), local L2 `device_*` and L3 flow tools refuse fast with `BUSY_FOREIGN_FLOW` (~50 ms measured, vs the ~44 s runner-leak cascade) while L1 reads stay free and `device_screenshot` serves pixels via its simctl fallback. A ~10 s teardown grace after the plugin's OWN flows prevents self-false-positives while WDA dies. The plugin's `maestro_run` is the **canonical** Maestro surface (it participates in the arbiter, parks the L2 runner, marks CDP stale, auto-repairs actions); the standalone maestro-mcp coexists for ad-hoc use and is refused against rather than collided with mid-flow. The device-open `FOREIGN_RUNNER_ACTIVE` warning remains. `RN_IOS_FOREIGN_GUARD=0` disables BOTH the warning and the refusal; the older `RN_IOS_FOREIGN_WARN=0` is a deprecated alias with the same (full) effect — if you set it to quiet the warning, know it now also drops the refusal. See `docs-site` → "Using rn-dev-agent with maestro-mcp".
161162

162163
**Device-session visibility + self-healing (#210, D1249).** The L2 `rn-fast-runner` is THE iOS `device_*` backend; Maestro/WDA is the L3 flow engine — **serialized, not competing** (there is no shared WDA session to "ride": maestro-runner spawns WDA per-flow and tears it down). Three reuse-first behaviors make the single iOS path coherent: (1) `cdp_status.deviceSession` reports `{ sessionOpen, rnFastRunner: 'alive'|'stale'|'dead', foreignRunner? }` (iOS-gated) so the runner's state is visible before any `device_*`; (2) `device_find/press/fill` **auto-spawn** the runner from the dispatch choke point when it's down and the XCUITest rig is **prebuilt** — a missing rig returns an actionable `RN_FAST_RUNNER_DOWN` (never a silent multi-minute `xcodebuild`); (3) `device_screenshot` **falls back to `xcrun simctl io screenshot`** whenever the runner can't serve it — including while a Maestro flow owns the device (it runs unleased via the arbiter's `FLOW_FALLBACK_TOOLS` allowlist; simctl is OS-level and can't conflict with WDA). Mid-flow needs map to non-conflicting mechanisms: **pixels → simctl**, **tree/state → `cdp_component_tree`/`cdp_store_state`** (CDP introspection coexists with a flow by design), **taps → not mid-flow** (the arbiter refuses them on purpose). A WDA W3C client was rejected — it would add a *second* XCUITest backend, the opposite of unifying.
163164

docs-site/src/content/docs/architecture.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ One mechanism per capability tier — **L1 + L2 coexist** (drive with XCTest, as
9090
| **L2 INTERACTION** | iOS `RnFastRunner` / Android `agent-device`; `cdp_interact` | primitive taps / types / scrolls | shared |
9191
| **L3 FLOW-REPLAY** | `maestro-runner` (Go + WDA) | whole-`.yaml` E2E flows | exclusive |
9292

93-
**Foreign runners** (e.g. the standalone `maestro-mcp`): L1 reads are always safe; on an L2 leak the device-session re-attaches rather than evicts (#188); `device_snapshot action=open` surfaces an informational `FOREIGN_RUNNER_ACTIVE` warning when a foreign session is present and no local flow is running.
93+
**Foreign runners** (e.g. the standalone `maestro-mcp`): L1 reads are always safe; on an L2 leak the device-session re-attaches rather than evicts (#188); `device_snapshot action=open` surfaces an informational `FOREIGN_RUNNER_ACTIVE` warning. Since #186, a LIVE foreign session (UDID-scoped, 5 s-TTL `ps` scan, fail-open) also makes local `device_*` and flow tools refuse fast with `BUSY_FOREIGN_FLOW` (~50 ms, vs the ~44 s runner-leak cascade) while CDP reads stay free and `device_screenshot` falls back to simctl; the plugin's `maestro_run` is the canonical Maestro surface. Opt out with `RN_IOS_FOREIGN_GUARD=0`.
9494

9595
## MCP server (CDP bridge)
9696

docs-site/src/content/docs/guides/maestro-interop.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@ When an L2 call sees the runner-leak sentinel after a maestro run, the device-se
1616

1717
## The `FOREIGN_RUNNER_ACTIVE` warning
1818

19-
When you `device_snapshot action=open` while a foreign maestro automation session is driving the same simulator (and rn-dev-agent is not itself running a flow), the open result carries an informational `meta.foreignRunner` and a `FOREIGN_RUNNER_ACTIVE` warning. It is a **heads-up only** — it does not block or change the open. It tells you the simulator is contended so you can expect a re-foreground if you interleave `device_*`.
19+
When you `device_snapshot action=open` while a foreign maestro automation session is driving the same simulator (and rn-dev-agent is not itself running a flow), the open result carries an informational `meta.foreignRunner` and a `FOREIGN_RUNNER_ACTIVE` warning. The open itself is never blocked.
2020

21-
The detection is UDID-scoped (it ignores maestro flows on other simulators and the idle maestro-mcp server). Opt out with `RN_IOS_FOREIGN_WARN=0`. Note: immediately after your *own* `maestro_run`, you may briefly see the warning while maestro's driver is still exiting — that's expected.
21+
Since #186 shipped, the contention handling goes further: while a foreign session is live (UDID-scoped detection, ~5 s cache), rn-dev-agent's `device_*` taps and `maestro_run` flows **refuse fast with `BUSY_FOREIGN_FLOW`** (~50 ms) instead of colliding into the multi-second runner-leak recovery cascade. L1 reads (`cdp_component_tree`, `cdp_store_state`, `cdp_navigation_state`) keep working throughout, and `device_screenshot` serves pixels via its OS-level simctl fallback. The guard clears within ~5 s of the foreign run ending; the first tap within ~10 s after rn-dev-agent's **own** flows is exempt (WDA teardown grace). Disable with `RN_IOS_FOREIGN_GUARD=0` (`RN_IOS_FOREIGN_WARN=0` is a deprecated alias — it disables the refusal too, not just this warning).
22+
23+
**Division of labor:** rn-dev-agent's `maestro_run` is the canonical Maestro surface — it participates in the device arbiter, parks the L2 interaction runner for the flow, marks CDP stale afterwards, and auto-repairs saved actions. Use the standalone maestro-mcp for ad-hoc exploration; when both target one simulator, rn-dev-agent now steps back cleanly instead of fighting.
24+
25+
The detection is UDID-scoped (it ignores maestro flows on other simulators and the idle maestro-mcp server). Opt out with `RN_IOS_FOREIGN_GUARD=0` (disables the refusal too). Note: immediately after your *own* `maestro_run`, you may briefly see the warning while maestro's driver is still exiting — that's expected, and the refusal guard explicitly exempts that window.
2226

2327
## L3 flows own the device
2428

docs-site/src/content/docs/tools/cdp/cdp_run_action.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Replay a learned action by id with end-to-end auto-repair. Loads the action from
1616
| `timeoutMs` | `number` | No | | | Maestro execution timeout per attempt (ms). Default 120_000. |
1717
| `trigger` | `enum: agent | ci | human` | No | | | RunRecord trigger annotation. Default "agent". CI calls should pass "ci". |
1818
| `forceReload` | `boolean` | No | | | GH #173: when true (default), acknowledge any human edit to the YAML as the new baseline before running so downstream repair does not abort with STALE_TARGET. Pass false for the strict Phase 129 "respect external edits" behavior (useful for CI replays of fixed baselines). |
19+
| `params` | `Record<string, unknown>` | No | | | Parameter bindings for the action\\'s $\{VAR\} placeholders, forwarded to maestro as -e KEY=VALUE on the first attempt AND the post-repair retry (GH #116). Keys must match /^[A-Z_][A-Z0-9_]*$/ (validated in maestro_run). |
1920

2021
## Usage
2122

docs-site/src/content/docs/tools/testing/maestro_run.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Execute a Maestro flow via maestro-runner. Pass flowPath for an existing .yaml f
1717
| `appId` | `string` | No | | | App bundle ID (auto-detected from app.json) |
1818
| `appFile` | `string` | No | | | iOS only — path to a built .app/.ipa for maestro-runner to reinstall on clearState. Auto-resolved from the flow appId when omitted (GH#201). |
1919
| `timeoutMs` | `number` | No | `120000` | min: 5000, max: 300000, integer | Execution timeout in ms |
20+
| `params` | `Record<string, unknown>` | No | | | GH #116: parameter bindings forwarded as -e KEY=VALUE for $\{KEY\} placeholders in the flow. Keys must match /^[A-Z_][A-Z0-9_]*$/ (validated in the handler). |
2021

2122
## Usage
2223

0 commit comments

Comments
 (0)