* 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.
* 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.
An auth hook that ran but returned no tenantId opted the deployment into
tenant attestation; falling back to the client's own claim (RPC body
meta.tenantId, aux-route x-agent-device-tenant header) let a holder of one
valid shared token impersonate any tenant on /rpc and on the diagnostics/
upload/download routes. resolveTrustedTenant() in the new
src/daemon/server/tenant-trust.ts is now the single seam both surfaces go
through and the only place that computes the resulting identity: hook
attests -> use it; no hook configured -> keep today's client-declared
behavior (loopback/dev unchanged); hook configured but silent with a
client-declared tenant -> refuse (401) instead of trusting the claim, and
no raw client-declared metadata survives into the dispatched request in
that case either.
Fixes#2095
* fix(daemon): let a retried open supersede the claim its aborted attempt abandoned
An `open` that fails after preparation keeps its device claim: the effects it
may have started are unproven, so releasing the device would hand an unknown
state to the next session. Nothing recorded that the claim binds no session,
so it stayed on disk for the daemon's whole life and every later open on that
device failed with DEVICE_IN_USE naming a session that no longer exists. A
`test --retries` run spent its whole retry budget on that conflict.
Rolling an unowned claim back now marks it abandoned instead of leaving it
untouched. Other processes still read a live claim, so the host-global fence
is unchanged; the daemon that abandoned it supersedes it on its next acquire.
* fix(daemon): stop an abandoned claim covering a transient-exclusive command
`acquireTransientDeviceClaim` treated any claim of this daemon as coverage, so
an abandoned open claim let install/push/prepare/shutdown run without taking
ownership and left the abandoned record behind. Only a claim that still holds
the device covers a transient command; an abandoned one is superseded under the
claim lock into the command's own transient claim, which its dispose releases.
* refactor: sink package-closed src modules into existing packages
Move closed modules into contracts, kernel, capture-kit, and ad-script,
and declare DaemonCommandDescriptor in core so R6/R9 can pin the remaining
provider-webdriver type cycle.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: keep contracts and capture-kit off generic sinks
Move interaction-outcome, snapshot warning rendering, and inventory ALS
behind focused owners, and plant R18/R70 domain-shape gates so they
cannot return as package export-map growth.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: drop moved implementation comments from owner modules
Names, types, and tests already carry those invariants; the relocated
files should not keep review-history or control-flow narration.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: drop the empty snapshot-quality layering zone
W1 moved the verdict into capture-kit and this PR moved warning rendering
into snapshot-presentation, so the ranked zone no longer has production files.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>