You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Daemon-to-daemon runner handoff exists, but both halves are simulator-gated:
detachIosSimulatorRunnerSessionsForShutdown skips any session whose device.kind !== 'simulator' (packages/platform-apple/src/runner/runner-session.ts:663).
tryAdoptRunnerSessionFromLease returns null for the same condition (packages/platform-apple/src/runner/runner-adoption.ts:64).
The lease is not the blocker: buildRunnerLease/writeRunnerLease run for every kind (runner-session.ts:263, :299) and already carry the identity facts adoption needs (RunnerLease, runner-lease.ts:41-63).
So a healthy physical runner is killed and rebuilt whenever the daemon restarts. Beyond the wasted xcodebuild boot, this closes the only door an embedding consumer has: a caller that cannot guarantee one long-lived daemon (parallel worktrees, short-lived agent invocations, an SDK host that recycles its daemon) cannot reuse a live runner, so it builds private runner contact instead. An external report describes driving a live LISTENER_READY runner over usbmux precisely to avoid taking our lease. That report is secondhand; the gate above is not.
Task
Extend the existing detach + adoption pair to physical Apple devices. Do not add a second lease or a second adoption path.
Gate on the real target.device.kind === 'device' is not "physical iOS": macOS hosts are kind: 'device', and physical tvOS is too. Decide explicitly whether the change covers tvOS and the xctest backend (core/physical-device-control.ts:114) or gate on physical iOS / Apple device-family with isMacOs excluded, and say so in the code.
Detach only a runner that reached ready.canWorkWithRunnerSession accepts 'starting' (runner-session-types.ts:124-126), and physical startup runs tens of seconds, so a graceful shutdown mid-boot would currently hand off a runner that never reached LISTENER_READY. Detach requires a state that proves it served requests.
Settle process lifetime before claiming handoff. The runner is spawned detached: true while the daemon owns its stdout/stderr pipes (runner-process-launch.ts:53-59). After the daemon exits, a later write by xcodebuild — device lock, replug, testmanagerd chatter — gets EPIPE/SIGPIPE, and post-handoff runner output no longer reaches runner.log. Prove the child survives, or restructure the handoff so it does. A live lease pid proves the host process only; it proves nothing about the on-device runner after a replug, reboot, or DDI remount.
Give the probe a route that can answer at cold start. Adoption's uptime probe already goes through the command route resolver (probeRunnerAnswersUptime → sendRunnerCommandOnce → createRunnerCommandRouteResolver, runner-transport.ts:21), but the tunnel-IP cache is process-global (runner-command-route.ts:23) and a new daemon starts with it empty. For a CoreDevice device usbmuxd does not list, the probe spends RUNNER_ADOPTION_PROBE_TIMEOUT_MS = 500 ms (runner-adoption.ts:38) including a devicectl tunnel fallback it cannot finish in time. The xctest backend is usbmux-only with no tunnel route (:54), so only coredevice needs a cold-tunnel budget; measure both and do not inherit 500 ms unexamined.
Rename honestly. If the function also detaches physical sessions, detachIosSimulatorRunnerSessionsForShutdown is wrong. It is part of the AppleRunnerClient surface (runner/client.ts:20,45,76, core/runner-client.ts:36, runner-operations-facade.ts:3, src/platform-runtime-apple-application-tools.ts:116), it is named in two .fallowrc.json baseline entries (:96, :162) that move with the rename, and it is named in a comment at src/daemon/server/daemon-idle-reap.ts:17. Rename across all of it in the same PR.
Keep AGENT_DEVICE_IOS_RUNNER_DETACH (runner-adoption.ts:46-49) as the switch for both kinds, and keep simulator-set redirects excluded (runner-session.ts:665-669) after confirming that path is unreachable for physical devices rather than assuming it.
Acceptance criteria
Physical evidence, per backend that stays in scope (coredevice, and xctest if covered), recorded with the device, iOS version, and commit SHA: start a session, restart the daemon, next command is served by the adopted runner. Proof is the adoption diagnostic plus startupTimings: adoption is already timed as adopt_detached_runner and the adopted session inherits the map (runner-session.ts:168-178), so the assertion is that step present and ensure_xctestrun / launch_xcodebuild (runner-session.ts:208, :246) absent.
Mock-only coverage does not count for the physical lanes: copied simulator tests with kind changed do not exercise the usbmux route and do not satisfy this issue.
Refusal matrix, one test each: lease absent, lease not stale, pid dead, pid recycled, artifact fingerprint mismatch, session_identity_mismatch, owner alive but owner-state dir gone, probe exceeds budget on the cold tunnel route.
Shutdown during runner startup tears down instead of detaching; a wedged/in-flight runner is decided by a named detector, not by state alone (runnerMainThreadBusy is the only occupancy fact available today).
Retained-after-close keep-hot behavior (Keep iOS simulator runner hot across session close #1013, AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS, runner-session.ts:517-522) is unchanged or deliberately changed with a stated reason — detaching a retained runner may be the intended payoff, so "close never leaves an adoptable lease" is not the requirement.
Simulator detach/adoption timings comparable to main on the simulator lane, with a stated threshold.
A general runner-ownership protocol, remote leases (ADR 0007, #2598), or a runner-status CLI surface. The session-state enum and response-decoder consolidation are #2662.
Why
Daemon-to-daemon runner handoff exists, but both halves are simulator-gated:
detachIosSimulatorRunnerSessionsForShutdownskips any session whosedevice.kind !== 'simulator'(packages/platform-apple/src/runner/runner-session.ts:663).tryAdoptRunnerSessionFromLeasereturnsnullfor the same condition (packages/platform-apple/src/runner/runner-adoption.ts:64).The lease is not the blocker:
buildRunnerLease/writeRunnerLeaserun for every kind (runner-session.ts:263,:299) and already carry the identity facts adoption needs (RunnerLease,runner-lease.ts:41-63).So a healthy physical runner is killed and rebuilt whenever the daemon restarts. Beyond the wasted
xcodebuildboot, this closes the only door an embedding consumer has: a caller that cannot guarantee one long-lived daemon (parallel worktrees, short-lived agent invocations, an SDK host that recycles its daemon) cannot reuse a live runner, so it builds private runner contact instead. An external report describes driving a liveLISTENER_READYrunner over usbmux precisely to avoid taking our lease. That report is secondhand; the gate above is not.Task
Extend the existing detach + adoption pair to physical Apple devices. Do not add a second lease or a second adoption path.
device.kind === 'device'is not "physical iOS": macOS hosts arekind: 'device', and physical tvOS is too. Decide explicitly whether the change covers tvOS and thexctestbackend (core/physical-device-control.ts:114) or gate on physical iOS / Apple device-family withisMacOsexcluded, and say so in the code.canWorkWithRunnerSessionaccepts'starting'(runner-session-types.ts:124-126), and physical startup runs tens of seconds, so a graceful shutdown mid-boot would currently hand off a runner that never reachedLISTENER_READY. Detach requires a state that proves it served requests.detached: truewhile the daemon owns its stdout/stderr pipes (runner-process-launch.ts:53-59). After the daemon exits, a later write byxcodebuild— device lock, replug,testmanagerdchatter — gets EPIPE/SIGPIPE, and post-handoff runner output no longer reachesrunner.log. Prove the child survives, or restructure the handoff so it does. A live lease pid proves the host process only; it proves nothing about the on-device runner after a replug, reboot, or DDI remount.uptimeprobe already goes through the command route resolver (probeRunnerAnswersUptime→sendRunnerCommandOnce→createRunnerCommandRouteResolver,runner-transport.ts:21), but the tunnel-IP cache is process-global (runner-command-route.ts:23) and a new daemon starts with it empty. For a CoreDevice device usbmuxd does not list, the probe spendsRUNNER_ADOPTION_PROBE_TIMEOUT_MS= 500 ms (runner-adoption.ts:38) including adevicectltunnel fallback it cannot finish in time. Thexctestbackend is usbmux-only with no tunnel route (:54), so onlycoredeviceneeds a cold-tunnel budget; measure both and do not inherit 500 ms unexamined.detachIosSimulatorRunnerSessionsForShutdownis wrong. It is part of theAppleRunnerClientsurface (runner/client.ts:20,45,76,core/runner-client.ts:36,runner-operations-facade.ts:3,src/platform-runtime-apple-application-tools.ts:116), it is named in two.fallowrc.jsonbaseline entries (:96,:162) that move with the rename, and it is named in a comment atsrc/daemon/server/daemon-idle-reap.ts:17. Rename across all of it in the same PR.AGENT_DEVICE_IOS_RUNNER_DETACH(runner-adoption.ts:46-49) as the switch for both kinds, and keep simulator-set redirects excluded (runner-session.ts:665-669) after confirming that path is unreachable for physical devices rather than assuming it.Acceptance criteria
coredevice, andxctestif covered), recorded with the device, iOS version, and commit SHA: start a session, restart the daemon, next command is served by the adopted runner. Proof is the adoption diagnostic plusstartupTimings: adoption is already timed asadopt_detached_runnerand the adopted session inherits the map (runner-session.ts:168-178), so the assertion is that step present andensure_xctestrun/launch_xcodebuild(runner-session.ts:208,:246) absent.kindchanged do not exercise the usbmux route and do not satisfy this issue.session_identity_mismatch, owner alive but owner-state dir gone, probe exceeds budget on the cold tunnel route.runnerMainThreadBusyis the only occupancy fact available today).AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS,runner-session.ts:517-522) is unchanged or deliberately changed with a stated reason — detaching a retained runner may be the intended payoff, so "close never leaves an adoptable lease" is not the requirement.mainon the simulator lane, with a stated threshold.docs/agents/device-verification.md.pnpm check:affected --run,pnpm check:layering,pnpm check:production-exportsgreen.Non-goals
A general runner-ownership protocol, remote leases (ADR 0007, #2598), or a runner-status CLI surface. The session-state enum and response-decoder consolidation are #2662.
Related: ADR 0005, #1403 (usbmux primary), #1013 (keep-hot), #2662.