* refactor: split client and Metro URL ownership (#2146)
* fix: preserve URL ownership loading contract (#2146)
* fix: preserve Metro bundle URL construction
* fix: keep the moved base URL implementation verbatim
* fix(daemon): compose stale-claim recovery from the dead owner's state dir (#2168)
The daemon's acquire-time reconciliation and startup sweep ran recovery
through the daemon's single composed gateway, so settling a foreign
state dir's dead claim cleared owned-process records by bare session id in
the RECONCILING daemon's own store — a live same-named session (default is
common across worktrees) lost its owned-processes.json to a foreign claim's
cleanup, while the dead owner's marker survived. Live-reproduced on main.
Every production reconciliation path — open-time admission, transient
admission, the startup sweep, and the daemonless CLI release — now goes
through createOwnerScopedDeviceClaimReconciler: one recovery composition per
claim, bound to that claim's recorded state dir (owned-process store and
session artifacts included), disposed after the transaction. Gateway state is
instance-scoped, so disposal shuts down only the transaction's own handles.
* test: prove the startup sweep composes recovery from the claim's state dir
Review follow-up on #2171: the unit tests exercised the owner-scoped
reconciler directly, so reverting the sweep callsite to the caller-scoped
reconciler stayed green. This regression boots the real daemon runtime with a
foreign dead claim whose recorded simctl recorder still runs as an orphan,
next to a live same-named session record in the daemon's own state dir
(purpose outside the startup reaper's selection). It proves the sweep
terminates the orphan, settles the foreign recording open -> completed,
clears the owner's process record in the OWNER's state dir, and leaves the
daemon's own record byte-identical. Observed red against a reverted sweep
callsite.
* test: prove open-time reconciliation composes recovery from the owner state dir
Review follow-ups on #2171:
- The acquire/open wiring had only live evidence. The new regression drives a
real open through the production request router against a device whose
foreign dead claim carries an orphaned recorder and durable recording
evidence, next to a live same-named session record under the daemon's own
state dir. It proves open-time reconciliation terminates the orphan,
settles the foreign recording, clears the owner's process record in the
OWNER's state dir, leaves the daemon's own record byte-identical, and hands
the claim to the opening session. Observed red against a reverted router
callsite.
- The startup-sweep regression now shuts its daemon runtime down in finally,
so an assertion failure cannot leak the runtime.
* test: restore an absent claims-dir override by deletion, not assignment
Review follow-up on #2171: both new regressions (and the pre-existing
prune-log test they mirrored) restored AGENT_DEVICE_CLAIMS_DIR by plain
assignment, which turns an absent previous value into the literal string
"undefined" and leaks global test state. Absent values are now restored by
deleting the variable.
* fix(apple): switch to manual code signing when a provisioning profile is set
CODE_SIGN_STYLE was hardcoded to Automatic even when
AGENT_DEVICE_IOS_PROVISIONING_PROFILE was configured, so xcodebuild rejected
the resulting PROVISIONING_PROFILE_SPECIFIER + CODE_SIGN_STYLE=Automatic
combination with "conflicting provisioning settings" on physical-device runs.
Fixes#2153
* fix: satisfy formatting and the test-file size ratchet
- oxfmt: wrap the long array literal in the new manual-signing test.
- runner-client.test.ts was already pinned at the 1000-line tripwire
(1577 lines); adding a test grew it past the pin, which the ratchet
test rejects by design ("extract instead of adding to a file over
the tripwire"). Extract the pure runner-cache-metadata.ts build-
settings tests (signing, bundle, performance, sandbox args) into a
new runner-cache-metadata.test.ts, shrinking runner-client.test.ts
to 1441 lines and lowering its pin to match.
A live-validation screen recording landed in the repository root through an
overly broad git add -A during the #2162 review round. Recordings default
their output path to the working directory; nothing references this file.
* docs+ux: make device ownership discoverable end to end
Complete the #1320 agent experience so 'busy? -> inspect -> choose or
release' is discoverable from every surface an agent actually reads:
- devices now projects the blocking claim owner per row (claimedBy with
session and workspace, observe-policy projection; provably dead owners are
excluded because the next open replaces them automatically), so an agent
told a device is busy can pick a free one from the same listing.
- help debugging gains a 'Device busy and ownership' section separating the
two DEVICE_IN_USE flavors and their exact recoveries.
- AGENTS.md documents both flavors; docs/agents/device-verification.md
retires the last ps/kill recovery guidance in favor of device status,
daemon stop --state-dir, and device release --stale (Stage 5 of #1320).
- ADR-0010 no longer calls DEVICE_IN_USE 'the only retriable code' without
naming the claim path's non-retriable override.
- The rendered cross-worktree claim error gains a help-conformance quiz case
binding (sample-output-device-claim-inspects-owner).
- README points at device status / device release --stale.
Part of #1320.
* fix: key ownership projection by canonical device identity end to end
Review findings on #2165:
- blockingClaimOwnersByDevice keyed claims and inventory rows by bare
device.id, so a live Android claim could project claimedBy onto an
unrelated same-id Apple/Harmony/Vega row, with scan order picking the
displayed owner. Both sides now use the canonical local device key
(claim.deviceKey against canonicalLocalDeviceKey of the row's claim
identity). The cross-family same-id regression was observed red against
the bare-id keying.
- The projection is now asserted across every hop the PR promises: client
normalization preserves well-formed claimedBy and drops malformed ones,
and the devices CLI formatter carries it through JSON data and renders
the text line (MCP shares the same serialization).
* feat: add stale device claim release and dead-end recovery guidance
Close the #1320 recovery loop for claims no daemon can settle on its own:
- agent-device device release --stale settles a provably dead owner's durable
resources through the same exact-owner reconciliation open and daemon
startup use, then clears the claim last — daemonlessly, composing a
local-only platform gateway in the CLI process. Live, uncertain, PID-reused,
and corrupt claims always fail closed and are reported with the reason.
- DEVICE_IN_USE conflicts whose recorded owner provably cannot release
(dead or superseded) now carry the exact release command as their recovery
instead of a status inspection that dead-ended.
- device status --stale now offers the matching release command when provably
dead owners are listed.
- daemon stop now warns in text output when a claim was orphaned (previously
visible only via --json) and names the status/release commands.
Part of #1320.
* test: cover release refusal branches, text rendering, and orphan warnings
Changed-line coverage on the stale-release slice was 69.33% against the 70%
gate: the refusal-reason branches, the text-mode outcome rendering with the
live-owner hint, and the daemon stop orphaned-claim warning had no tests.
Cover them directly; the misnamed-claim-file refusal is also pinned.
* test: prove resources gate stale release and the scan-to-lock race stays closed
Review follow-ups on #2162:
- Two end-to-end CLI regressions run device release --stale through the real
local gateway against a dead owner whose state dir holds an attributable
durable screen-recording/app-log record: an owner-mismatched record and
unreadable resource evidence both retain the claim (app-log-owner-mismatch,
app-log-descriptor-invalid), proving cleanup must reach a terminal state
before the claim can be deleted.
- A deterministic race regression holds the per-device claim lock while a
release is in flight, replaces the claim with a successor before releasing
the lock, and proves the transaction reports changed without reconciling or
touching the successor's claim.
* fix: bind stale-claim recovery to the dead owner's state dir
Review P1 on #2162: the CLI composed one gateway from the caller's state
dir, so recovery for a foreign stale claim could clear the caller's live
owned-process record when both used the same session name — Apple recording
cleanup clears by session id through the gateway-composed store.
Recovery is now composed per claim, with the owned-process record store and
session artifact paths bound to the stale claim's recorded state dir, and
disposed after each transaction. The regression writes two dead claims with
one shared session name in different state dirs and proves each recovery is
composed from that claim's own state dir, never the caller's.
* feat(apple): reclaim retained runners under device-claim authority
Implement the #1320 retained-runner rule: a daemon holding the host-global
device claim may stop and replace a warm XCTest runner whose owner no longer
holds that claim. Previously worktree B's open failed with an unstructured
COMMAND_FAILED (IOS_RUNNER_OWNED_BY_OTHER_DAEMON) for up to five minutes
after worktree A closed, until A's daemon idled out.
- Runner leases now record deviceClaimProtocol: 1; takeover is gated on the
lease declaring claim arbitration, so owners from pre-claims builds are
never preempted.
- The claim-authority probe is daemon-bound through the existing runner-owner
seam and answers from the claim store by process identity; unbound embedders
answer false and keep today's refusal.
- Disposal now skips device-wide runner container-app termination when the
on-disk lease is owned by someone else, so the losing daemon's idle stop or
shutdown cannot kill the successor's runner on the shared simulator.
- Help topics updated: the live-owner runner rejection now names claim
arbitration instead of being unconditional.
Live-validated with two daemons sharing one claim store against a throwaway
simulator: live-owner open still rejects with structured DEVICE_IN_USE;
after close, the contender opens in ~8s with the lease re-owned while the
loser daemon is still alive; stopping the loser afterwards leaves the
winner's runner healthy.
* fix(apple): make disposal ownership check and device-wide teardown one lease-locked operation
Review P1 on #2160: cleanupRunnerSessionResources read the on-disk lease
token and then terminated the device-global runner container bundles without
holding the runner-lease lock, so a loser could pass its check while a
successor's reclaim was in flight and then kill the successor's runner.
The ownership check, the container-app termination, and the lease release now
run as one operation under the runner-lease lock — the same lock a successor
holds for its entire reclaim-and-publish window — on every disposal path.
Callers already inside the lock (startup abort, lease-publish failure,
stopIosRunnerSession) declare leaseLockHeld instead of re-acquiring the
non-reentrant lock; if the lock cannot be acquired, all device-wide teardown
is skipped and an own unreleased lease turns stale on process exit.
The two-owner regression starts loser disposal inside a held successor
window, completes the takeover, and proves the loser neither terminates the
successor's runner nor touches its lease. Observed red against the pre-fix
code. Live-revalidated twice with overlapping daemon stop and takeover open
on a real simulator.
* fix(apple): match runner takeover authority by canonical device identity
Review P1 on #2160: the authority probe matched any active claim with the
same raw device id, while claims are canonically scoped by platform family +
Apple OS + id — an Android claim whose serial equals an Apple runner id could
authorize destructive takeover of a live Apple runner (and the prior probe
regression used an Android fixture, blessing exactly that).
The probe now receives the full DeviceInfo through the runner-owner seam and
the daemon answers with an exact canonical-local-device-key lookup: family,
Apple OS, and id must all match the held claim, which also replaces the store
scan with a direct keyed read. prepareRunnerLeaseForStartup takes the device
rather than a bare id so the lease seam can never degrade the match.
Adversarial regressions: a same-id Android claim grants no authority for the
Apple device (and the true Apple claim does) at the probe, and the lease-seam
test pins that the probe receives full family/OS identity, never a bare id.