Skip to content

[agent-cli] Keep device leases alive while Metro and builds run - #146

Merged
vonovak merged 6 commits into
vonovak/android-device-instancefrom
vonovak/lifecycle-1-lease
Oct 15, 2026
Merged

vonovak merged 6 commits into
vonovak/android-device-instancefrom
vonovak/lifecycle-1-lease

Conversation

@vonovak

@vonovak vonovak commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

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, detached dev, start, and build steps. AGENT_CLI_NO_DEVICE=1 disables 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

  • Unit: 5,006 pass, including timer tests and two wiring tests that fail if either wrapper is removed.
  • E2E: dev-test and dev-detach-test, 73 of 73.
  • Dogfooding: with Metro running and no verb in between, the binding's expiresAt moved forward at +5 and +10 minutes (read from the binding file).
  • Documentation update: repository formatting, document links and stale-reference checks pass.

Comment thread packages/@expo/agent-cli/llp/0030-one-device-per-worktree.rfc.md Outdated
Comment thread packages/@expo/agent-cli/llp/0030-one-device-per-worktree.rfc.md Outdated
});
if (state === 'lost' && states.get(file) === 'extended') {
warn(
`The ${platform} device lease expired or disappeared. Run dev --${platform} to bind it again.`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

vonovak and others added 6 commits October 15, 2026 00:25
…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>
@vonovak
vonovak force-pushed the vonovak/lifecycle-1-lease branch from 086e145 to fabb4ac Compare October 15, 2026 06:47
@vonovak
vonovak merged commit 391512f into main Oct 15, 2026
14 of 15 checks passed
@vonovak
vonovak deleted the vonovak/lifecycle-1-lease branch October 15, 2026 11:54
vonovak added a commit that referenced this pull request Oct 15, 2026
…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>
vonovak added a commit that referenced this pull request Oct 15, 2026
…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>
vonovak added a commit that referenced this pull request Oct 15, 2026
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>
vonovak added a commit that referenced this pull request Oct 15, 2026
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>
vonovak added a commit that referenced this pull request Oct 15, 2026
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>
vonovak added a commit that referenced this pull request Oct 15, 2026
…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>
vonovak added a commit that referenced this pull request Oct 15, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants