Files
callstack__agent-device/src/platform-runtime-apple-runner-owner.ts
Michał Pierzchała 5fddf1ce14 feat(apple): reclaim retained runners under device-claim authority (#2160)
* 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.
2026-08-31 14:32:28 +02:00

18 lines
738 B
TypeScript

import type { RunnerDeviceClaimAuthorityProbe } from '@agent-device/platform-apple/runner-owner';
/** Root-composed daemon ownership inputs consumed by the Apple runner host. */
export async function configureAppleRunnerLeaseOwnerStateDir(
stateDir: string | undefined,
): Promise<void> {
const { setRunnerLeaseOwnerStateDir } = await import('@agent-device/platform-apple/runner-owner');
setRunnerLeaseOwnerStateDir(stateDir);
}
export async function configureAppleRunnerDeviceClaimAuthorityProbe(
probe: RunnerDeviceClaimAuthorityProbe | undefined,
): Promise<void> {
const { setRunnerDeviceClaimAuthorityProbe } =
await import('@agent-device/platform-apple/runner-owner');
setRunnerDeviceClaimAuthorityProbe(probe);
}