Skip to content

[agent-cli] Reap devices from deleted and expired worktrees - #149

Merged
vonovak merged 7 commits into
vonovak/lifecycle-3-smoke-releasefrom
vonovak/lifecycle-4-reap
Oct 15, 2026
Merged

vonovak merged 7 commits into
vonovak/lifecycle-3-smoke-releasefrom
vonovak/lifecycle-4-reap

Conversation

@vonovak

@vonovak vonovak commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

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 [agent-cli] status and dev --detach show the worktree's device, and status sends an unbound worktree to dev (llp/0035 §Lifecycle output) #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.

@vonovak
vonovak added this pull request to stack #150 October 13, 2026 08:50
@vonovak vonovak changed the title vonovak/lifecycle 4 reap [agent-cli] Reap devices from deleted and expired worktrees Oct 13, 2026
@vonovak
vonovak force-pushed the vonovak/lifecycle-4-reap branch 2 times, most recently from 7fd7f67 to b71f74e Compare October 13, 2026 15:47
@vonovak
vonovak force-pushed the vonovak/lifecycle-4-reap branch from b71f74e to 35de192 Compare October 13, 2026 16:24
@vonovak
vonovak force-pushed the vonovak/lifecycle-4-reap branch from 35de192 to ec1f551 Compare October 13, 2026 19:25
@vonovak
vonovak force-pushed the vonovak/lifecycle-4-reap branch from ec1f551 to c026666 Compare October 13, 2026 21:09
@vonovak
vonovak force-pushed the vonovak/lifecycle-4-reap branch from c026666 to f936f0a Compare October 14, 2026 08:31
@vonovak
vonovak marked this pull request as ready for review October 14, 2026 08:32
@vonovak
vonovak requested a review from Kudo October 14, 2026 08:32
@vonovak
vonovak force-pushed the vonovak/lifecycle-4-reap branch from f936f0a to 1bc5a46 Compare October 14, 2026 09:26
@vonovak
vonovak force-pushed the vonovak/lifecycle-4-reap branch from 1bc5a46 to 0d56cc2 Compare October 14, 2026 14:02
if (cleanup.action === 'shutdown') {
writeBindingFile(file, { ...binding, expiresAt: tools.now().toISOString() });
} else {
removeBindingFile(file);

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] reapSection still removes the binding before a delete or kill, and runCleanupAsync does the device action afterward. Parking a simulator keeps the file, so a failed shutdown can run again. A failed simctl delete or a thrown emulator kill cannot: the row is already gone. dev:stop now puts released: false on that run's report and exits 20, but the next reap has nothing to see. acquireSectionAsync still ignores runCleanupAsync's reports, so dev and smoke still succeed. A created simulator with no binding is never chosen again, so it stays up until someone deletes it by hand.

Suggestion: Keep the file until delete or kill succeeds (a second short lock section is enough), or write the binding back when the device action fails. Surface that failure from acquireSectionAsync the same way dev:stop sets deviceError, and add a test that a failed delete is removed on the next reap.

return shutdownSimulatorAsync(tools, device.udid);
if (device.backend === 'local-android' && device.origin.kind === 'spawned' && action === 'kill') {
const result = killEmulatorIfOurs(tools, device.origin);
return { ok: !result.failed, reason: result.reason, shutDown: result.killed };

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.

[suggestion] killEmulatorIfOurs returns killed: false with a reason and no failed flag when the pid is gone or is no longer that AVD and port. ok: !result.failed treats that as success, so the report gets released: true, no error, and drops the decline reason. The human line says the device was released. Combined with the binding already being removed, a caller cannot tell a kill from a pid that was left running.

Suggestion: Treat a declined kill as not released, and put result.reason on the report even when the process was intentionally not signalled. Leave shutDown as result.killed.

vonovak and others added 7 commits October 15, 2026 08:34
bindingIdentity and bindIosSectionAsync move unchanged from
deviceBinding/index.ts to deviceBinding/iosAcquire.ts. The reap that
follows wraps this section, and the move keeps that diff readable.
No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…worktrees (llp/0033 §Reap)

Nobody runs dev:stop --release in a worktree that git worktree remove
deleted, so its simulator or emulator instance stays up and bound. The
next acquisition by dev or smoke, and the next dev:stop of any worktree
on the machine, now reap other worktrees' stale bindings under the
registry lock and run the device actions after it: a deleted worktree's
created simulator is deleted, an expired one is shut down when an iOS
inventory is at hand, and a spawned emulator instance of a deleted or
expired worktree is killed. dev:stop --json lists them under reaped.
AGENT_CLI_NO_DEVICE=1 turns the reap off with every other device action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ogram is reported device-gone (llp/0035 §Records)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he next reap and names the step by hand (llp/0035 §Reap)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vonovak
vonovak force-pushed the vonovak/lifecycle-4-reap branch from 0d56cc2 to ff96df5 Compare October 15, 2026 06:47
@vonovak
vonovak merged commit 678fe31 into main Oct 15, 2026
15 checks passed
@vonovak
vonovak deleted the vonovak/lifecycle-4-reap 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
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.

---------

Co-authored-by: Claude Opus 5.5 <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
…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