Repository navigation
[agent-cli] Keep device leases alive while Metro and builds run - #146
Conversation
3371bfd to
a6e0b57
Compare
a6e0b57 to
acfddf8
Compare
acfddf8 to
d55731e
Compare
3ddcff8 to
086e145
Compare
| }); | ||
| if (state === 'lost' && states.get(file) === 'extended') { | ||
| warn( | ||
| `The ${platform} device lease expired or disappeared. Run dev --${platform} to bind it again.` |
There was a problem hiding this comment.
[bug] A lease that goes from extended to lost warns Run dev --${platform}. That is not a command this CLI prints. deviceCommand() is npx @expo/agent-cli dev --<platform> --detach --wait-ready. This line runs in the process that is already the dev server (or in a build step inside it). Following it starts a second foreground dev, which runs the plan again, instead of the detach path that re-acquires with reuseOnly while the server stays up. backend is in scope and unused, so a lost cloud file gets the same local command. The CHANGELOG entry documents this text.
Suggestion: Warn with deviceCommand(platform) for a local binding. For backend === 'cloud', name the dev command with --eas (RFC §Readers). Update the CHANGELOG line to the same command.
…th 3a and 3b (llp/0030 §Delivery) A reviewer could not see why PR 4 needed all of its changes. Each of 4a to 4f now answers one question in its own llp/0033 section, with its tests named by their real titles. 4a to 4d still merge with 3a and 3b, so main never has a window where `dev` creates devices and nothing gives them back; 4e and 4f merge after review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…device lease (llp/0033 §Lease timer) A binding lives 60 minutes, and only a verb renewed it. A Fast Refresh session or a long native build sends no verb, so the next reap could take the device away while Metro still served it. `withLeaseExtendedAsync` now wraps `runDevServerAsync` (the dev server of `dev`, its detached child, and `start`) and each build step of `dev`. Every five minutes it extends every binding file of the worktree, including files bound after it started, and warns once each time a lease is lost. Two unit tests check the wiring itself, because no e2e run reaches a five-minute tick and reverting both wrappers left every other test green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it touches no local binding Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… a session (llp/0035 §Lease) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
086e145 to
fabb4ac
Compare
…b uses it (llp/0035 §iOS binding) (#134) PR 3a of llp/0035 (one device per worktree), based on main after the prep in #133 merged. Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149 → #151 → #152. EAS session binding (#139) is separate from this stack. The five design files `llp/0035` to `llp/0035 §EAS session binding` land in the first commit. ## The question How does a worktree get an iOS simulator nobody else has, and how do the other verbs find it? ## What changes for an agent - `dev --ios` binds a simulator this CLI created for the worktree, named `agent-cli <digest>`, boots it with `simctl bootstatus -b`, and runs every `expo run:ios` and install step with `--device <udid>`. The binding is a JSON file under `<expo home>/agent-cli/bindings/`, held by a 60 min lease that use renews; every write runs under one machine-wide lock. Two worktrees get two simulators. - `navigate`, `runtime:reload`, `runtime:stop`, `status` and the installed-app readers read the binding and never list devices to choose one. Without a platform flag, any `up` binding wins. A worktree with no usable binding is refused with `NO_BOUND_DEVICE` and the one `dev` command that fixes it. Main's `NO_DEVICE` and `NO_IOS_DEVICE` are gone. - `dev --detach` on a running server boots the worktree's own parked simulator and refuses `not-reusable` otherwise. A device refusal in the detached child reaches the agent with its own code, exit and How line, through the child verdict. - `smoke --ios` takes its simulator from the binding and leaves it bound; it no longer shuts down the simulator it booted. - Android is unchanged until 3b: its first-`adb`-device rung is injected into the rung loop. - The EAS fallback without `--eas` accepts the `.env.eas-simulator` id only, until PR 5 binds sessions. ## Commits 1. The module `src/deviceBinding/` with its unit tests and the five LLPs. No caller. 2. The wiring: the resolver callback, `withDevice`, the detach relay, the rung loop in every reader, smoke, the deletions. 3. The e2e suite, the LLP sentences that said "first booted", and the changelog. ## Decisions taken while implementing (to fold back into llp/0035 §iOS binding) - The child verdict carries the error code and exit (`Error code <CODE> exit <N>`), written only when `__EXPO_AGENT_CLI_DETACHED=1`. - A failed boot is `DEVICE_UNAVAILABLE` reason `boot-failed`, exit 20, "run the command again". - `withDevice` runs after the `--plan` return and the refusals; main's port block stays before `--plan`, because the plan prints the port. - Smoke's install phase now honours `installNeeded` from the boot result: a created simulator's disk holds nothing. - `status`'s next action still names the plan's `dev --ios` command, not the `--detach --wait-ready` form; changing it touches every status snapshot. - `src/deviceBinding/` has `ios.ts`, `errors.ts`, `events.ts` and `tools.ts` beyond the RFC's file list, all within the 250-line budget. ## Verification - Typecheck, lint (no new warnings), `bun run format:check`. - Unit: 229 files, 4842 tests passed. Every test named in llp/0035 §iOS tests exists under that name. - Build, then e2e: 41 files, 835 tests passed; the device cases run with `AGENT_CLI_NO_DEVICE=0` on macOS. - Not yet run: the live checks of llp/0035 §iOS tests (two real worktrees, `navigate` wall time, a cold `simctl create`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…137) Each worktree gets its own read-only Android emulator instance on a free console port. `dev --android` acquires it, build/install steps receive its serial, and device commands use the recorded binding instead of whichever emulator happens to be first. The registry reserves ports held by every listed emulator, including booting/offline instances. Cleanup checks whole `-avd` and `-ports` values before killing an owned process, and validates the binding identity so release or failed-boot cleanup cannot remove a replacement binding. Windows install-test coverage is restored. ## Stack Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149 → #151 → #152. EAS session binding (#139) is separate from this stack. Based on #134. The four review-fix commits are included in this PR, ending at `95a1184a`. ## Validation The split was previously checked with typechecks, formatting, lint and unit tests on each layer, plus focused lifecycle/dev/stop/smoke integration tests and local-device dogfooding. This publication preserves the reviewed branch tip; tests were not rerun for the stack and PR-description changes. --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Stopping Metro can leave the worktree's devices allocated. `dev:stop --release` shuts down and parks a created iOS simulator or ends a spawned Android emulator after confirming this worktree's server has stopped. Plain `dev:stop` keeps devices; explicitly selected devices are preserved. A remaining or replacement server prevents release. When stop refuses because ownership cannot be verified, JSON now reports the observed process liveness and port occupancy instead of falsely reporting that both are gone. Unmeasured facts are `null`. Missing lock evidence no longer claims the server was never started by the CLI. Signal authorization and device-release policy are unchanged. ## Stack Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149 → #151 → #152. EAS session binding (#139) is separate from this stack. Based on #146. Binding-identity and failed-boot cleanup protections come from #137. ## Validation - 47 focused stop/release unit tests and 14 built-CLI stop tests passed; new regressions failed before the reporting fix. - At the integrated #152 tip: 5,138 unit tests across 241 files and 136 built-CLI dev-detach/status/device-lifecycle/stop tests passed. Build, typecheck, lint (existing warnings only), and formatting passed. The selected cold simulator creation/install/release smoke test passed in 127.55 seconds. The full smoke suite was not completed; partial bounded runs are not a full-suite pass. These fix regressions use subprocess/device stubs; native acceptance was not repeated for this source-fix pass. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
An unattended smoke run should return the device resources it acquired. Release a device bound by smoke's started dev server through `dev:stop --release`, or release the boot phase's own binding when reusing an existing server. A device already bound and running before the run stays available; `--no-start` acquires and releases nothing. Name the bound device and its cleanup result in human output and `environment.boundDevice`. Follow-up commands account for whether the run stopped its server or released its device, and preserve the caller's port. Tests cover replacement bindings, reused servers, kept devices and iOS/Android restart guidance. ## Stack Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149 → #151 → #152. EAS session binding (#139) is separate from this stack. Based on #147 (B, `vonovak/lifecycle-2-release`). ## Validation This layer is rebased over the stop-reporting correction in #147; its smoke implementation is unchanged. At the integrated #152 tip: 5,138 unit tests across 241 files and 136 built-CLI dev-detach/status/device-lifecycle/stop tests passed. Build, typecheck, lint (existing warnings only), and formatting passed. The selected cold simulator creation/install/release smoke test passed in 127.55 seconds. The full smoke suite was not completed; partial bounded runs are not a full-suite pass. These fix regressions use subprocess/device stubs; native acceptance was not repeated for this source-fix pass. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A deleted worktree cannot run `dev:stop --release`, and expired leases leave device resources behind. Device acquisition and `dev:stop` reap other worktrees' stale bindings: delete created simulators for deleted roots, shut down expired simulators when an iOS inventory is available, and end owned Android emulator instances. Explicit devices are preserved. Registry changes happen under the lock; device actions run afterward, including when acquisition refuses. Results appear under `reaped` in `dev:stop --json`. Cross-worktree results identify `projectRoot` and report `devServerCleanup: "not-checked"`; deleted-root human output also explains that CLI/Metro processes may remain. A late last-build metadata write skips an already deleted project root. ## Scope and limits Reaping uses lease expiry as its liveness signal. After a long host sleep it can park a simulator while Metro still runs. This PR cleans device resources; it does not terminate orphan CLI/Metro processes after worktree deletion. Safe server cleanup needs durable server ownership information and remains a separate follow-up. The metadata guard does not make the existing write sequence atomic against simultaneous directory deletion. ## Stack Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149 → #151 → #152. EAS session binding (#139) is separate from this stack. Based on #148. The first commit extracts iOS acquisition; subsequent changes implement reaping and clarify its reporting. ## Validation - 152 device-binding/last-build/stop-lifecycle unit tests and two built-CLI device-lifecycle tests passed. Reporting and deleted-root write regressions failed before the fixes. - At the integrated #152 tip: 5,138 unit tests across 241 files and 136 built-CLI dev-detach/status/device-lifecycle/stop tests passed. Build, typecheck, lint (existing warnings only), and formatting passed. The selected cold simulator creation/install/release smoke test passed in 127.55 seconds. The full smoke suite was not completed; partial bounded runs are not a full-suite pass. These fix regressions use subprocess/device stubs; native acceptance was not repeated for this source-fix pass. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…evice on dev) (#151) ## Why A person wants the agent on their own simulator or USB phone. Without this PR there is no way to name one. Two worktrees must not share it: if worktree A holds `dogfood-explicit`, `dev --device dogfood-explicit` in worktree B must refuse. No release or reap may shut it down. ## What changes - `dev --device <name|udid|serial>` binds that device as `explicit`. Release and reap never shut it down, delete it or kill it. - It refuses a device another live worktree holds (exit 20), an ambiguous name (lists the matches), and a second device while this worktree's dev server runs (names both). - It takes over another worktree's expired binding, and deletes the own created simulator it replaces. - An Android serial needs only `adb devices -l`, not the emulator package. - `dev --plan --device` prints a `Device:` line (bind, take over, delete, or refuse), and JSON `explicitDevice`. A refusing plan offers the refusal's How and exits 0. - A How that repeats the caller's `dev` command keeps its flags and adds `--detach --wait-ready`. ## Review guide 1. llp/0033 §--device on dev. 2. `src/deviceBinding/explicit.ts`. Look closely at `readExplicitChoice` and `bindExplicitSection` (transfer and delete under the lock). 3. `index.ts`, `preview.ts`, `emulator.ts`, `src/plan/rerun.ts`, `resolveOptions.ts` (reads `__EXPO_AGENT_CLI_CALLER_ARGV` once). 4. Tests: `explicit-test.ts`, `resolveOptions-test.ts`, `devAsync-test.ts`. ## Stack Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149 → #151 → #152. EAS session binding (#139) is separate from this stack. Stacked on PR D (`vonovak/lifecycle-4-reap`). ## Validation - Unit: 5076 pass. The adb-only test failed in preview and acquisition before the fix. - E2E on macOS: `device-lifecycle-test` gains the plan block; the log records e2e green, without counts. - Dogfood: sessions 1, 2, 3, 5 and 7. A concurrent `--device` race had one winner. Session 7 checked the How lines. - Not run: no USB phone. Live tier with `AGENT_CLI_LIVE_UDID` not run. ## Notes - Commit 2 fixes Kudo's finding (explicit Android needed the emulator package). - `explicitDevice` gives the device facts as a sentence, not fields (M3, open). - General changes: plan follow-ups drop repeated commands, and `PORT_TAKEN_AFTER_RETRY` uses `rerunDevCommand`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…tatus sends an unbound worktree to dev (llp/0035 §Lifecycle output) (#152) `status` can recommend the wrong platform or lose a chosen Metro port when a startup has not published its lock or its server has stopped. It can also recommend device commands for a worktree whose device lease has expired. This PR reports the worktree's binding and produces recovery commands using the known development context. ## Behavior - `status --json` exposes each binding's platform, backend, identity, origin, state and expiry. Detached `dev` names its bound device in human output and under `device` in JSON. - A real `dev` invocation retains only parsed platform, requested port, device, build backend and app target in `.expo/agent-cli-last-dev.json`. Dry runs do not write it. This record is a recovery hint, not process-liveness evidence. - Status chooses the explicit internal platform override, then the saved request, then a sole bound platform, then the project default. A different explicit platform discards the other saved choices. No new public platform flags are added to `status`. - Recovery preserves an explicit local status port, the current own-server port, or the saved requested port. A port observed serving another project is omitted. Remote URL ports are not treated as local Metro requests. - Recovery uses #151's quoted retry builder and restores the dev plan steps when replacing a verification action. `rule` and `target` retain their documented plan meaning. - Rebinding guidance preserves the connected-app, web, EAS-session and unsupported-device-host exceptions. A worktree without a binding but with its own running server is told to stop that server before restarting to bind a device. ## Stack Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149 → #151 → #152. EAS session binding (#139) is separate from this stack. Based on #151. Review `src/dev/lastRequest.ts`, `src/status/statusAsync.ts`, `src/deviceBinding/summary.ts`, and detached dev's device reporting. ## Validation - Android startup without a lock, sole Android binding, stopped-request recovery, explicit overrides, device-name quoting, corrupt/unwritable context, dry-run behavior, foreign ports and restored plan steps have regression coverage. - A built-CLI Android start → stop → status test verifies retained platform, port and target choices. - At the integrated #152 tip: 5,138 unit tests across 241 files and 136 built-CLI dev-detach/status/device-lifecycle/stop tests passed. Build, typecheck, lint (existing warnings only), and formatting passed. The selected cold simulator creation/install/release smoke test passed in 127.55 seconds. The full smoke suite was not completed; partial bounded runs are not a full-suite pass. These fix regressions use subprocess/device stubs; native acceptance was not repeated for this source-fix pass. The saved request does not establish that a native build is running or fix separate startup-ownership issues. Device origin describes how it became bound, not what the current invocation did. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Devices have a 60-minute lease, but a long-running Metro or native build may go longer without a device command. Renew the worktree's leases every five minutes while the dev-server or build runner is active, including bindings created after the runner starts. Renewal stops with the runner; a lost lease is reported once.
The wrappers cover
dev, detacheddev,start, and build steps.AGENT_CLI_NO_DEVICE=1disables renewal. Timer and wiring tests cover lease renewal. The design documents describe lifecycle behavior and dependencies.Stack
Review bottom-up in stack #150: #134 → #137 → #146 → #147 → #148 → #149 → #151 → #152. EAS session binding (#139) is separate from this stack.
Based on #137 (
vonovak/android-device-instance).Validation
dev-testanddev-detach-test, 73 of 73.expiresAtmoved forward at +5 and +10 minutes (read from the binding file).