fix(apple): name an Xcode too old for its host macOS as a runner startup failure

TH
15m ago
+739-77

Summary

An Xcode older than the macOS it runs on cannot load its own DTServiceHub: AMFI refuses its version-restriction entitlement. Every xcodebuild test-without-building then writes DTServiceHubClient failed to bless service hub for simulator … into runner.log and the runner never serves a command, while the caller saw only Runner did not accept connection — a failure the recovery table orders a session restart, a resend, and a clean rebuild of the very binary this macOS refuses to load.

runner.log now gets its own startup classification: details.reason: "simulator_service_hub_unavailable", terminal on every recovery axis (connectRetry, retryable, restartBeforeSend, restartAfterReadinessPreflight, artifactSuspect all false), with hint naming the fix on the daemon host. logPath and diagnosticId survive normalizeError.

Runner did not accept connection: this Xcode cannot start its simulator service on this macOS
reason: simulator_service_hub_unavailable
hint:   … (`xcode-select -s /Applications/Xcode.app`, or DEVELOPER_DIR=… for the daemon process) …

It also stops waiting. Classifying at the deadline would still have made the reporter wait out the connect retries they measured at ~90 s, so the connect loop reads the launch tail on each attempt while the session has yet to answer and ends itself: the same terminal error the deadline path produces, thrown before the next connect attempt is dialed. Since the round-2 review the verdict is the classifier's own: each launch-log row declares the cause it names, and the loop throws the built error only when the recovery table says a retry cannot help, so a tail holding two signatures gets the first row's winner on both axes. Bounded and cancellable — one 64 KB tail read, the same one an error would already quote, taken through the session's own reader from this generation's offset, no subprocess. The refusal is not recoverable within a launch, so the first attempt that sees it is the last attempt. Scope is a session that has never answered, so a runner that already served commands is never re-judged by its launch log, and a refusal from an older runner generation stays outside the window.

Per the maintainer's decision, the cause is classified from runner.log only — no DTServiceHub preflight or entitlement probe, which would depend on Xcode's internal layout. Recovery keys on the typed reason; the classifier row is the sole text matcher, over the launch output. Both connect-stage failures (early exit, and a child still alive at the deadline) now resolve their cause through one classifier, so message, reason, hint and logPath cannot disagree. Out of scope: agent-device-client dropping developerDir under daemonBaseUrl (intended for remote daemons).

Along the way: the documented reason vocabulary was missing simulator_set_destination_not_found at both exhaustive surfaces (this docs page and the versioned CLI help), so it is added there, and test/repo-guards/runner-startup-reason-docs.test.ts checks both against RUNNER_STARTUP_FAILURE_REASONS so the published vocabulary cannot drift again.

Fixes #3449

Validation

Head cf83c868d. pnpm check:affected --run --head cf83c868d — all runnable checks passed, exactCommit=true (receipt .tmp/validation/run-YVEuy2/receipt.json): format, lint, typecheck, layering, fallow, build, vitest-related, docs-links and docs-prose ran green; coverage, the native/device/replay lanes and provider integration stay with GitHub and all pass at this head. The linux.gap failures from the previous round are gone — #3740 landed. iOS Smoke Tests (job 117178907306), including "fixture-backed iOS simulator E2E smoke", passed at cf83c868d: the runner boots through the changed connect loop, preflights, serves the Settings replay, the gesture replay, and the full fixture E2E tier=smoke.

Regression evidence — pnpm exec vitest run --project apple-runner over the owning test files, production modules reverted (file copies, not stashes):

beforehead
base: 4 failed — both bless causes unnamed, recovery axes not terminal, missing fixturecf83c868d: 911 passed (whole project)
d91886f76 (round-1 production): the both-signatures test fails and burns its full 5 s connect budget — the reviewer's regression exactlysame tests: 38 passed, no attempt dialed, failure well under budget

The fail-fast tests are a bless refusal already in the launch log ends the connect loop on its first attempt and one tail holding two launch-log causes gets the classifier's own winner, terminal (row order, not the loop, decides; fetch never called). Two neighbouring tests hold the boundary — an older generation's refusal and a session that already answered keep ordinary retry behaviour.

Live evidence for the changed connect loop (source-built CLI at cf83c868d, fresh simulator 3449 Hub Verify, iPhone 17 Pro / iOS 27.0, deleted afterwards): cold open Settings (runner rebuilt for the new UDID, 7.0 s request), then snapshot -i (19 interactive nodes) and press @e7 (tapped, General screen shown). runner.log for that launch moves the session starting→ready: AGENT_DEVICE_RUNNER_WAITING → AGENT_DEVICE_RUNNER_LISTENER_READY → AGENT_DEVICE_RUNNER_PORT=65268, and every command after it was served by that runner. Per-attempt cost of the 64 KB tail read (readRunnerLogTail, the production reader): ~49 µs on this real session log, ~74 µs worst case against a 2 MB log returning the full 64 KB.

Still no bless repro: this needs an Xcode older than the host macOS supports, which no device here has. The fixture is a reported-log-excerpt from the two reporters' runner.logs; its two matched phrases are quoted verbatim.

🤖 Generated with Claude Code