Skip to content

[agent-cli] dev binds the worktree's own iOS simulator, and every verb uses it (llp/0031) - #134

Open
vonovak wants to merge 13 commits into
mainfrom
vonovak/ios-device-binding
Open

vonovak wants to merge 13 commits into
mainfrom
vonovak/ios-device-binding

Conversation

@vonovak

@vonovak vonovak commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

PR 3a of llp/0030 (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/0030 to llp/0034 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/0031)

  • 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/0031 §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/0031 §Tests (two real worktrees, navigate wall time, a cold simctl create).

🤖 Generated with Claude Code

vonovak added a commit that referenced this pull request Oct 8, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 8, 2026
…y line stays the dev command, the relayed refusal keeps its data, and the old simulator reader goes (llp/0031 §How `dev` uses it)

A Mac without an Android SDK reaches `absent` again, because the iOS
inspection answered. A NO_BOUND_DEVICE refusal keeps `dev` as its Try
line; --print-url is an Or: door. The detached child's code row carries
`data`, and only the Try line next to it counts. smoke keeps the How line
in its boot reason and prints the acquire line. The EAS fallback asks the
service only when the dotenv names a session. The booted-only simulator
reader is deleted. The named tests produce the child's log through
logCmdError, forward a --device the pin replaces, and pin the acquire line
before the report. 0031 says the order built and the decisions taken;
0030 gains the boot-failed row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 8, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 8, 2026
…y line stays the dev command, the relayed refusal keeps its data, and the old simulator reader goes (llp/0031 §How `dev` uses it)

A Mac without an Android SDK reaches `absent` again, because the iOS
inspection answered. A NO_BOUND_DEVICE refusal keeps `dev` as its Try
line; --print-url is an Or: door. The detached child's code row carries
`data`, and only the Try line next to it counts. smoke keeps the How line
in its boot reason and prints the acquire line. The EAS fallback asks the
service only when the dotenv names a session. The booted-only simulator
reader is deleted. The named tests produce the child's log through
logCmdError, forward a --device the pin replaces, and pin the acquire line
before the report. 0031 says the order built and the decisions taken;
0030 gains the boot-failed row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vonovak
vonovak force-pushed the vonovak/ios-device-binding branch from 89a1f71 to 1363cd1 Compare October 8, 2026 12:52
@vonovak
vonovak added this pull request to stack #135 October 8, 2026 12:53
vonovak added a commit that referenced this pull request Oct 8, 2026
The prerequisite outside the llp/0030 stack: `apps/eas-example`, which
the live tier and the device-binding PRs dogfood, moves from SDK 57 to
SDK 58. Independent of #132 to #134.

## Why now

- `@expo/cli` 58 matches `run:android --device` by serial. The Android
instances of llp/0032 are read-only copies of one AVD that share a name,
so only the serial tells them apart.
- SDK 58 embeds the fingerprint that `status --explain` reads through
`expo-constants`; on 57 the installed app cannot be checked.
- The CLI targets SDK 58 and the 57 fallbacks are to be removed, so the
dogfood app should not keep exercising them.

## What changed

`expo install --fix` against SDK 58, with two versions held one patch
back by the repo's two-day minimum release age in `bunfig.toml`: `expo`
58.0.5 instead of 58.0.6 and `@expo/metro-runtime` 58.0.12 instead of
58.0.13. `expo install --check` reports only those two. A follow-up
bumps them once they clear the policy.

| Package | Before | After |
|---|---|---|
| expo | 57.0.19 | 58.0.5 |
| @expo/metro-runtime | 57.0.15 | 58.0.12 |
| expo-dev-client | ~57.0.18 | ~58.0.11 |
| expo-status-bar | ~57.0.1 | ~58.0.3 |
| react, react-dom | 19.2.3 | 19.3.0 |
| react-native | 0.86.3 | 0.88.0-rc.3 |

`expo config` resolves to `sdkVersion` 58.0.0. The lockfile follows.

## What to expect

- The fingerprint changes, so the local `live-eas` suite needs a
re-seeded finished build on EAS; the cloud-e2e workflow on this PR
builds its own dev client and proves the app on 58.
- No CLI code changes.

🤖 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 8, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 8, 2026
…y line stays the dev command, the relayed refusal keeps its data, and the old simulator reader goes (llp/0031 §How `dev` uses it)

A Mac without an Android SDK reaches `absent` again, because the iOS
inspection answered. A NO_BOUND_DEVICE refusal keeps `dev` as its Try
line; --print-url is an Or: door. The detached child's code row carries
`data`, and only the Try line next to it counts. smoke keeps the How line
in its boot reason and prints the acquire line. The EAS fallback asks the
service only when the dotenv names a session. The booted-only simulator
reader is deleted. The named tests produce the child's log through
logCmdError, forward a --device the pin replaces, and pin the acquire line
before the report. 0031 says the order built and the decisions taken;
0030 gains the boot-failed row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vonovak
vonovak force-pushed the vonovak/ios-device-binding branch from 81c0096 to c060cd7 Compare October 8, 2026 14:14
@vonovak
vonovak removed this pull request from stack #135 October 9, 2026 07:50
vonovak added a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 9, 2026
…y line stays the dev command, the relayed refusal keeps its data, and the old simulator reader goes (llp/0031 §How `dev` uses it)

A Mac without an Android SDK reaches `absent` again, because the iOS
inspection answered. A NO_BOUND_DEVICE refusal keeps `dev` as its Try
line; --print-url is an Or: door. The detached child's code row carries
`data`, and only the Try line next to it counts. smoke keeps the How line
in its boot reason and prints the acquire line. The EAS fallback asks the
service only when the dotenv names a session. The booted-only simulator
reader is deleted. The named tests produce the child's log through
logCmdError, forward a --device the pin replaces, and pin the acquire line
before the report. 0031 says the order built and the decisions taken;
0030 gains the boot-failed row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vonovak
vonovak force-pushed the vonovak/ios-device-binding branch from c060cd7 to 96718db Compare October 9, 2026 07:50
@vonovak
vonovak added this pull request to stack #142 October 9, 2026 07:50
vonovak added a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 9, 2026
…y line stays the dev command, the relayed refusal keeps its data, and the old simulator reader goes (llp/0031 §How `dev` uses it)

A Mac without an Android SDK reaches `absent` again, because the iOS
inspection answered. A NO_BOUND_DEVICE refusal keeps `dev` as its Try
line; --print-url is an Or: door. The detached child's code row carries
`data`, and only the Try line next to it counts. smoke keeps the How line
in its boot reason and prints the acquire line. The EAS fallback asks the
service only when the dotenv names a session. The booted-only simulator
reader is deleted. The named tests produce the child's log through
logCmdError, forward a --device the pin replaces, and pin the acquire line
before the report. 0031 says the order built and the decisions taken;
0030 gains the boot-failed row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vonovak
vonovak force-pushed the vonovak/ios-device-binding branch from f0555ae to 019b827 Compare October 9, 2026 08:18
@vonovak
vonovak marked this pull request as ready for review October 9, 2026 15:34
@vonovak
vonovak requested review from Kudo and davidmokos October 9, 2026 18:06
throw deviceUnavailableError('unreadable', { platform: 'ios', path: file });
}
const own = read.kind === 'binding' ? read.binding : null;
const choice = chooseIosDevice({ own, inventory, reuseOnly, digest: digestForRoot(projectRoot) });

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.

[🚨 critical] Two concurrent acquisitions for the same unbound worktree both snapshot an empty inventory, so the second removes the first run's new binding and creates another simulator; both boot, but subsequent verbs target only the second one. Revalidate the inventory when the binding changed while waiting for the lock before declaring its device gone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fix in 0df189f

if (running) {
if (
reuseBoundDevice &&
options.platform === 'ios' &&

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.

[🚨 critical] On Linux or Windows, repeating dev --ios --detach against a running server now calls xcrun and fails, although fresh runs intentionally skip local iOS acquisition on those hosts. Apply the same macOS guard used by bindsDevice.

Suggested change
options.platform === 'ios' &&
process.platform === 'darwin' &&
options.platform === 'ios' &&

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed in 226b5e0

Comment thread packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts
vonovak added a commit that referenced this pull request Oct 12, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vonovak
vonovak force-pushed the vonovak/ios-device-binding branch from e01db94 to 0ef7cfd Compare October 12, 2026 19:54

@Kudo Kudo left a comment

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.

there're a couple places to have ${projectRoot}.expo-home. we should use path.join for it

const root = canonicalizeExistingPath(projectRoot);
const now = Date.now();
const file = path.join(
`${projectRoot}.expo-home`,

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.

should use path.join(projectRoot, '.expo-home', ...)

@vonovak
vonovak force-pushed the vonovak/ios-device-binding branch from aaa36f0 to 2219ccb Compare October 13, 2026 21:09
@vonovak
vonovak requested a review from Kudo October 14, 2026 08:32
vonovak and others added 13 commits October 14, 2026 15:59
…d under a lock and read by every verb (llp/0031 §The module)

src/deviceBinding/ holds the records, the lock, the lease, the choice, the
inspect table and the rung loop of llp/0030, with the iOS boot through
simctl bootstatus -b. No caller yet. The reap and the Android half arrive
with llp/0032 and llp/0033. The five design files land with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ads the binding (llp/0031 §How `dev` uses it, §Readers, §smoke)

The resolver binds for a serving draft before its presence probe; a
building draft is bound after the refusals; every run and install step
carries --device <udid>, and the open takes the bound device. dev --detach
on a running server reuses the parked simulator or refuses not-reusable,
and the child's DEVICE_* and NO_BOUND_DEVICE refusals reach the caller
unchanged through the verdict's code row. navigate, runtime:*, status and
the installed readers walk the rung loop; the EAS fallback accepts the
dotenv id only. smoke's iOS boot is the registry's, left bound. The
first-booted probe, pickSimulator, the iOS boot and NO_DEVICE go.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…or, two worktrees get two simulators, and the docs name the bound device (llp/0031 §Tests, §Docs)

The stub xcrun binds the booted simulator it lists; the smoke boot cases
assert the registry file and bootstatus -b; a two-worktree dev case pins
distinct E2E-CREATED-* udids on each run:ios --device. The first-booted
sentences of llp/0004, 0005, 0022, 0025 and 0026 read the bound device.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y line stays the dev command, the relayed refusal keeps its data, and the old simulator reader goes (llp/0031 §How `dev` uses it)

A Mac without an Android SDK reaches `absent` again, because the iOS
inspection answered. A NO_BOUND_DEVICE refusal keeps `dev` as its Try
line; --print-url is an Or: door. The detached child's code row carries
`data`, and only the Try line next to it counts. smoke keeps the How line
in its boot reason and prints the acquire line. The EAS fallback asks the
service only when the dotenv names a session. The booted-only simulator
reader is deleted. The named tests produce the child's log through
logCmdError, forward a --device the pin replaces, and pin the acquire line
before the report. 0031 says the order built and the decisions taken;
0030 gains the boot-failed row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…runtime that lists one, not the newest runtime's only iPhone (llp/0030 §Choice)

iOS 27.1 lists iPhone Duo alone, and no app rendered on it. The choice now
walks the available runtimes newest first and takes the first with an
`iPhone <n> Pro`, else an `iPhone <n>`; only when no runtime has one does
the newest runtime's first iPhone apply. The RFC's §Choice sentence says
so, and §Risks gains the observation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… pass on Windows (llp/0031 §How `dev` uses it)

The live-cloud suite runs dev --ios --no-open on a Linux worker to serve for an EAS session it opens itself; the acquire ran xcrun there and failed. iOS simulators exist on macOS only, so the callback binds nothing elsewhere, as on main. On Windows the function-length guard split on LF and never saw a lone closing brace in a CRLF checkout, and the binding's project root is the canonical path, which is a drive path there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…o dev creates on a runtime that works (llp/0030 §Choice)

simctl keeps listing such a simulator with isAvailable false. The reuse took it, bootstatus failed, the failed boot kept the binding, and every retry picked it again. The choice and the listing now read an unavailable simulator as missing: the binding is removed and a new simulator is created, and read verbs report gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…031 §Tests)

Review: #134 (review 5505059377). Every e2e reader of
the per-project Expo home rebuilt `${projectRoot}.expo-home` by string
concatenation. One helper, `expoHomeOf(projectRoot)` in e2e/utils.ts,
now returns `path.join(projectRoot, '.expo-home')`; `spawnAgentCli` and
every reader use it, so the home sits inside the fixture project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vonovak
vonovak force-pushed the vonovak/ios-device-binding branch from 2219ccb to 308d897 Compare October 14, 2026 14:02
if (read.kind !== 'binding') {
return null;
}
if (listing.kind === 'missing') {

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] After a failed boot, releaseWorktreeDevicesAsync snapshots the udid and origin, lists that simulator, then under the lock only checks that a binding file still exists. It never checks that the file still names that udid. A missing listing deletes whatever binding is there now, and any other listing expires that binding and may simctl shutdown the snapshotted udid. A concurrent acquire that replaced the binding therefore loses its file or its lease, and the shutdown can hit the simulator this release listed rather than the one the file names. The same section also ignores a rewrite of the same udid: a second acquire that already renewed the lease and booted still gets that lease expired and the simulator shut down.

Suggestion: Under the lock, return without writing when the device id differs from the snapshot, or when boundAt or expiresAt changed. Only then delete, expire, or shut down.

if (outcome === 'lost') {
throw noBoundDeviceError('gone', { platform, cause: 'expired', path: inspection.path });
}
return binding.device;

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] useBoundDeviceAsync treats any unexpired binding as extended and then returns the device from the earlier inspection. extendLeaseAsync does not compare device ids, so a rebind to a different simulator between the inspect and the lock still counts as success. findBoundDeviceAsync then deep-links that old udid (navigate, runtime:reload, runtime:stop) while the file names the new one. The lock-timeout warn-and-continue path is the documented one; this success path is not.

Suggestion: If the binding under the lock has a different device id, treat the outcome as lost instead of extending it and returning the inspected device.


/** An `iPhone <n> Pro`, else an `iPhone <n>`, by the name `simctl` lists the type under. */
function mainstreamIphone(types: { identifier: string; name?: string }[]): string | null {
for (const pattern of [/^iPhone \d+ Pro$/, /^iPhone \d+$/]) {

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] mainstreamIphone only accepts iPhone <n> Pro and iPhone <n>. iPhone <n> Pro Max matches neither, and find keeps the first Pro in simctl order rather than the newest model. parseNewestIosRuntime then returns on the first runtime with a match, so a newer runtime whose only pro phone is a Pro Max loses to an older runtime's Pro.

Suggestion: Treat iPhone <n> Pro Max as mainstream and prefer the newest matching model on the newest runtime that has one, before falling through to an older Pro.

@@ -0,0 +1,53 @@
# 0032: `dev` binds its own Android emulator instance

**Type:** Plan

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.

if these llps are all for "one-device-per-worktree", it would be good to merge these into single llp rather then having multiple. that would be easier to find correct llp in the future

after merge the llp, you may need to ask your agent to review all implementation code in this pr or upstack pr to link to correct llp number and section

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