Repository navigation
Hand off physical iOS runner sessions across daemon restarts #2681
Description
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/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 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. - Detach only a runner that reached ready.
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. - Settle process lifetime before claiming handoff. The runner is spawned
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. - Give the probe a route that can answer at cold start. Adoption's
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. - Rename honestly. If the function also detaches physical sessions,
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. - 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, 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. - Mock-only coverage does not count for the physical lanes: copied simulator tests with
kindchanged 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 (
runnerMainThreadBusyis 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
mainon the simulator lane, with a stated threshold. - Physical verification follows
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.
Activity
docs(evidence): move the #2681 device checklist into docs/evidence
fix(ios-runner): give the detached runner a log it owns (#2681)
docs(evidence): move the #2681 device checklist into docs/evidence
fix(ios-runner): give the detached runner a log it owns (#2681)
docs(evidence): move the #2681 device checklist into docs/evidence
fix(ios-runner): give the detached runner a log it owns (#2681)
docs(evidence): move the #2681 device checklist into docs/evidence
fix(ios-runner): give the detached runner a log it owns (#2681)
docs(evidence): move the #2681 device checklist into docs/evidence
fix(ios-runner): give the detached runner a log it owns (#2681)
docs(evidence): move the #2681 device checklist into docs/evidence
fix(ios-runner): give the detached runner a log it owns (#2681)
feat(ios-runner): hand physical iOS runners across daemon restarts
feat(ios-runner): hand physical iOS runners across daemon restarts (c…