Skip to content

feat(ios-runner): read developer mode and disk image state from the device - #2690

Merged
thymikee merged 16 commits into
mainfrom
apex/2683-device-readiness-facts
Sep 21, 2026
Merged

thymikee merged 16 commits into
mainfrom
apex/2683-device-readiness-facts

Conversation

@thymikee

@thymikee thymikee commented Sep 19, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #2688. Three things were guesses. verifyDeveloperModeForIosRunner read the Mac's DevToolsSecurity -status — a host setting, never the iPhone's toggle — and threw with no reason; resolveIosDevicectlHint answered "developer disk image" with Developer Mode advice; and runner.log, append-only across commands, let an earlier command's crash explain whatever failed next.

devicectl device info details carries the two device facts apart, so they become two reasons in #2680's vocabulary while the host probe keeps devtools_security_developer_mode_disabled and is asked second: when a device and a Mac are both wrong, the caller gets the reason they can act on without admin rights. An image complaint is believed only while tunnelState=connected and bootState=booted, so a sleeping phone publishes the unreadable shape, not a verdict. Each remedy is worded once in core/devicectl.ts and travels on the report. Each command draws its runner.log boundary after draining queued appends and reads only bytes past it, under the startup budget and signal.

Only the toggle refuses the run before the build (#2683 review). Since iOS 17 CoreDevice mounts the personalized developer disk image on demand during build and launch, a phone that has just been rebooted reports ddiServicesAvailable: false while the very next build clears it — refusing there turned a self-clearing state into a failed run. So the preflight hands the states back and the session's startup catch attaches them to whatever startup threw, which is what makes a warm derived cache that fails at install and an external xctestrun that never launches say the same thing a cold build does (#2690 review).

It becomes the reason only when the failure carries no reason of its own and no rule row matched (#2690 review). classifyRunnerStartupFailure now reports matched, and the catch that classifies carries that answer onward as details.startupRuleMatched — necessary because a wrapper buries the tool's text a level too deep for the rows to read again. Without it, a row that deliberately names no cause publishes build_failed_unclassified like any unmatched failure, and a just-rebooted phone's image state would have been published over a conflicting-settings failure.

$ agent-device prepare ios-runner --platform ios --json   # any startup step, image down, nothing else named it
{"code":"COMMAND_FAILED","message":"xcodebuild build-for-testing failed",
 "details":{"reason":"device_developer_disk_image_unavailable","developerDiskImage":"unavailable"}}

Each command's log boundary is bounded too: draining is a prelude to sending, so it waits on a deadline and the caller's signal rather than the command's own clock, and an append the disk refused is recorded instead of dropped — with bytes unaccounted for there is no trustworthy offset, so no marker is drawn and the tail goes unread. The device report has one type name, re-exported by runner/host.ts under the name the reader owns.

Closes #2683. 24 files, 604 production lines added.

Validation

At a371ad284f (rebased onto #2688 bcfed35b4c, which is on main fc59620932): build, format, check:quick, check:layering, check:gate-manifest, check:command-docs, check:production-exports, apple-runner (533) and the full unit lane (10,665) pass, and fallow audit --base origin/main reports no findings across the stack's 28 changed files. check:affected --run stays with CI.

Seven claims fail red when the fix is removed: unmarked log-tail reads, a boundary drawn before queued appends, ddiServicesAvailable: false from a sleeping device, a remedy worded outside its owner, host-before-device probing, a device state published where a rule row had already looked at the failure, and a marker measured over an append the disk refused. Making the enrichment stop at the build catch instead of the startup catch takes the warm-cache launch case down with it.

The committed payload masks serial, ECID, snapshot name and tunnel IP behind a test. Live evidence is pending (.device-evidence/CHECKLIST.md §#2683), so the state pairings stay invented-shape; item 4 now records which of three outcomes a real image-down device produces, since "the build clears it" is the outcome this round removed the refusal for. Startup spends one best-effort devicectl read per physical-device session; it can see a stale tunnel IP, which is why it never gates a run alone.


Added after the Phase B device run (8951f93496)

An early exit now carries the disk-image state too. enrichRunnerStartupFailureWithDeviceStates
had exactly one call site, the launch_xcodebuild catch, so the only image-down state iOS 27 actually
produces — a locked phone that lets the build finish and kills xcodebuild test-without-building
instead — never passed through it and published no developerDiskImage. The facts read before the
build now travel on the session (startupDeviceStates) and the early-exit builder routes through the
same enrichment, so the fact lands on the failure a real device produces. A failure that already
proved a cause keeps that reason and hint; the device only adds its fact, never overwrites a claim.
Captured on thymikee-iphone at 52ab61e: inside a verified image-down window prepare failed as
Runner did not accept connection (xcodebuild exited early) (exit 70) with no developerDiskImage
which is what this closes.

Why adoption runs before the readiness check, and what that means for a retained lease.
createRunnerSession measures adopt_detached_runner first (runner-session.ts:176) and
verify_device_readiness only afterwards (:198-209). A live runner already serving commands is adopted
without a readiness re-check, so with a retained lease this command returns success where a fresh
start refuses. Defensible — Developer Mode was on when that runner started — but it is why the first
attempt at the Developer-Mode-off capture succeeded until the lease was cleared.

@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-21 12:04 UTC

@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.71 MB 4.72 MB +5.6 kB
Package (unpacked) 4.71 MB 4.72 MB +5.6 kB
Package (download) 1.40 MB 1.41 MB +1.9 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.2 ms 27.0 ms -0.2 ms
CLI --help 78.4 ms 77.1 ms -1.3 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 62c0fa5. Reading Developer Mode and disk image state from the device is useful, but three issues block this.

On iOS 17+, CoreDevice mounts the personalized developer disk image on demand. So a healthy phone right after a reboot can report ddiServicesAvailable: false while Developer Mode is on. The preflight at runner-device-readiness.ts#L72 runs before the build (runner-session.ts#L200) and throws device_developer_disk_image_unavailable on that state, so the build that would mount the image never starts. prepare ios-runner can then fail on a just-rebooted phone where it works today. Could the preflight refuse only on developerMode: 'disabled', and attach the disk image state as evidence to the build or launch failure instead? A live reboot-then-prepare run that shows the field stays true would also settle this.

The readiness fact is not published once, as #2683 asks. The device path keeps its hint text in DEVICE_READINESS_HINTS (runner-device-readiness.ts#L19-L25), and the tool-output path keeps a second copy at devicectl.ts#L304 and #L311. The two already differ on the Developer Mode advice, although the comment at devicectl.ts#L300 says both publish the same string. host.ts also copies the state union by hand. Each reason's hint should be one constant next to the fact: readIosDeviceReadiness in core returns { reason, hint } with the state, devicectl.ts uses the same constants, and the runner only throws what it gets.

There is no device evidence yet. The 'disabled' value of developerModeStatus and ddiServicesAvailable: false exist only in synthesized fixtures, and .device-evidence/CHECKLIST.md#L10 lists the live run as pending. Is ios-device-info-details.json a real capture? It records no source. If the real payload never uses 'disabled', device_developer_mode_disabled cannot fire. Please run checklist steps 1-5 at the head on a physical iPhone and show details.reason in the prepare ios-runner JSON error for Developer Mode off and for the missing disk image. Commit the raw deviceProperties values as a captured fixture, and add a step that reboots and unlocks a healthy phone and confirms prepare ios-runner still succeeds.

Could core's readIosDeviceReadiness own both the states and the verdict, so that runner-device-readiness.ts becomes one throw and the host.ts copy and DEVICE_READINESS_HINTS go away? Could the check-affected gate change land as its own PR? Either way, #2688 owns the reason vocabulary and needs to land first.

Not blocking: the nestedDevice(result).deviceProperties fallback at physical-device-coredevice.ts#L256 reads a shape the real capture does not have and causes most of the complexity in parseIosDeviceDetailsPayload; readIosDeviceDetails and nameIosDeviceReadinessObstacle are exported but used only in their own files; and the offset tests do not reach the offset-0 fallback that is still at runner-failure-diagnostics.ts:55.

Compatibility & Provenance fails on the exports and parser complexity that this PR adds (#L151, #L238, #L65), so it comes from this diff. The Smoke Tests failure is a simulator scroll: the long-press fixture element never became visible. The readiness preflight returns early for simulators, so that failure is likely unrelated.

Next: share one hint and verdict owner between the device and devicectl paths, stop refusing before the build on ddiServicesAvailable: false, and add physical-iPhone evidence for Developer Mode off, a missing disk image, and a fresh reboot.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 27f3836. This follows up on the earlier review at 62c0fa5 (#2690 (comment)). The disk-image refusal and the evidence gap from that review remain.

The disk-image refusal still runs before the build. nameIosDeviceReadinessObstacle still returns device_developer_disk_image_unavailable when Developer Mode is enabled and the disk image is unavailable (runner-device-readiness.ts#L90), and it runs in verify_device_readiness before build and launch (runner-session.ts#L199). The new gate treats the image answer as unreadable only when the tunnel or boot state is off (physical-device-coredevice.ts#L400). On iOS 17+, CoreDevice mounts the personalized disk image on demand, so a phone that was just rebooted, unlocked and cabled can likely report tunnelState: connected, bootState: booted and ddiServicesAvailable: false until the next xcodebuild or devicectl launch. The preflight then refuses exactly the state that the build would fix, and prepare ios-runner fails after every reboot. Could the preflight refuse only on developerMode === 'disabled', the one state that no later startup step can change, and carry developerDiskImage as a detail on the build or launch failure that classifyRunnerStartupFailure already publishes? That means flipping the test at runner-device-readiness.test.ts#L123 so the run proceeds, and adding a startup-failure test that expects developerDiskImage in the failure details. The post-reboot device run below would confirm or refute the iOS 17 behavior.

The disabled and ddiServicesAvailable: false fixture pairings are still marked invented-shape (.device-evidence/CHECKLIST.md#L95), and the checklist section for this preflight has not been run. So a preflight that can refuse every physical-device run ships with no device observation behind it. On a real iPhone, please show raw devicectl device info details on a healthy device and again right after a reboot, unlocked and cabled, before any Xcode or devicectl launch, with tunnelState, bootState and ddiServicesAvailable; agent-device prepare ios-runner --platform ios --json succeeding from that post-reboot state; and, with Developer Mode off, prepare returning details.reason: device_developer_mode_disabled with its hint. The fixtures can then move to captured.

Smoke Tests fails on the iOS simulator at wait text Agent Device Tester with wait_capture_stalled. The readiness preflight returns early for simulators (runner-device-readiness.ts#L44), and the only simulator-path change is the runner log flush before each command (runner-session.ts#L775), so this looks unrelated. There are no conflicts with the base branch; this PR is stacked on #2688, which needs to land first.

Next: before the build, refuse only when Developer Mode is disabled, carry the disk image state on build or launch failures, and attach the #2683 checklist run on a real iPhone, including the post-reboot state.

@thymikee
thymikee added this pull request to stack #2697 September 19, 2026 18:54
@thymikee
thymikee force-pushed the apex/2683-device-readiness-facts branch from 27f3836 to f2d605a Compare September 19, 2026 19:45
@thymikee

Copy link
Copy Markdown
Member Author

This is a follow-up on the earlier review at 27f3836 (#2690 (comment)). At f2d605a the code still has one behavior bug and the device evidence is still missing.

The pre-build refusal in runner-device-readiness.ts:90 is unchanged. With Developer Mode on, tunnelState connected, bootState booted, and ddiServicesAvailable false, readIosDeviceReadiness still publishes available:true/developerDiskImage:'unavailable', and assertDeviceReadinessForIosRunner still throws in the verify_device_readiness step (runner-session.ts:199), before build and launch. On iOS 17+ CoreDevice mounts the personalized disk image on demand, so a just-rebooted, unlocked, cabled phone can show exactly that triple until the next xcodebuild or devicectl launch. Preflight then refuses a state the build itself would clear, so prepare ios-runner and every physical-device session start can fail right after a reboot where it works today, with a hint that tells the user to wait for Xcode. Can this rule instead refuse only on developerMode === 'disabled', the one state no later startup step changes, and carry developerDiskImage as a detail on the build/launch failure that classifyRunnerStartupFailure already publishes? That means flipping the assertion in runner-device-readiness.test.ts:123 so the run proceeds, and adding a startup-failure test that checks the image state in the details. A live post-reboot capture showing ddiServicesAvailable staying true would refute this mechanism instead — is that worth trying before committing to the fix?

The #2683 section of .device-evidence/CHECKLIST.md:94 (steps 1-7) still has not been run, and the PR body itself says the pairings for developerModeStatus:'disabled' and ddiServicesAvailable:false are invented-shape. Nothing shows the real payload ever spells disabled, so device_developer_mode_disabled may be unreachable, and nothing shows the post-reboot triple from the finding above actually occurs. A preflight that can refuse every physical-device run is shipping with no device observation behind it. Please run checklist steps 1-6 at this head on a physical iPhone and paste: raw devicectl device info details on a healthy device and again immediately after reboot/unlock/cable, before any Xcode or devicectl launch, with tunnelState, bootState, and ddiServicesAvailable; prepare ios-runner --platform ios --json succeeding from that post-reboot state; and, with Developer Mode off, the error carrying details.reason device_developer_mode_disabled plus its top-level hint. Then the fixtures can move from invented-shape to captured.

Not blocking: flushRunnerLogAppends in runner-io.ts:60 awaits the append chain with no deadline or abort signal ahead of every command's own timeout clock and swallows rejections silently, and host.ts:107 keeps two names (core's IosDeviceReadiness and the derived IosDeviceRunnerReadiness) for one type that tests now import from two different modules; both can be taken or left.

I could not test iOS 17+ CoreDevice mount-on-demand behavior myself, so the post-reboot triple is inferred from CoreDevice semantics and stays a likely finding until the capture above lands. I did not re-review the payload parser, remedies plumbing, or log-boundary tests beyond the four focus invariants; those were settled at 27f3836. I ran no tests locally. CI reports all 21 checks passing at f2d605a, the Smoke Tests simulator failure seen at 27f3836 is gone, and the only simulator-path change in this delta, the per-command runner.log boundary, is byte-identical to the earlier revision. The diff view for this PR is polluted by the base move from f4930d6 to 85bd4de, so I reconstructed the logical delta by diffing the two per-ref patches instead.

Before merge, the pre-build disk-image refusal needs to change to refuse only on developerMode === 'disabled' and carry the image state on the build/launch failure, and the #2683 checklist run needs to happen on a real iPhone, including the post-reboot state.

@thymikee
thymikee force-pushed the apex/2683-device-readiness-facts branch from f2d605a to 120ae34 Compare September 20, 2026 11:54
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto #2688 9621905975 (on main d688926167). New head: 120ae34e32.

The pre-build DDI refusal is removed. preflightIosRunnerDeviceReadiness (packages/platform-apple/src/runner/runner-device-readiness.ts) refuses for exactly one state — developerMode === 'disabled' — which is the one no build, install or launch step can change and no build log names. bootState: 'booted' + tunnelState: 'connected' + ddiServicesAvailable: false now builds: iOS 17+ CoreDevice mounts the personalized image on demand during build and launch, so a phone that has just been rebooted reports it down while the very next build clears it, and refusing there converted a self-clearing state into a failed run. The preflight hands the states back; runner-session.ts passes them into ensureXctestrunArtifact; the build catch attaches details.developerDiskImage and asks the table module to corroborate.

device_developer_disk_image_unavailable survives, and it is only produced where the state is corroborated twice over: core published unavailable only with tunnel up and booted, and the reason is claimed only on a build that named no cause of its own. A build that named one keeps it — corroborateRunnerBuildFailureWithDeviceStates returns the classified pair untouched for signing_no_development_team, which team-id-failure-on-device-with-image-down pins. The flipped case is runner-device-readiness.test.ts ("builds anyway" + "carried forward for the failure it explains") and unclassified-build-on-device-with-image-down in the startup-failure fixtures drives the real build catch to the reason, the image remedy and details.developerDiskImage: "unavailable".

The helper lives beside classifyRunnerStartupFailure rather than in the readiness module on purpose: runner-artifact.ts reaching for host.tscore/physical-device-coredevice.tscore/devicectl.ts pushed app-lifecycle, doctor-facade and runner-op over their eager-closure budgets, which eager-closure-budgets.test.ts caught in CI-style locally.

The two non-blocking items are in. flushRunnerLogAppends now takes a deadline and the caller's signal and propagates instead of swallowing: a refused append is recorded per log path and cleared by the next successful append, and the wait is bounded at 2s rather than inheriting the command's clock, because draining is a prelude to sending (captureRunnerLogAttempt clamps it). With bytes unaccounted for there is no trustworthy offset, so captureRunnerLogAttempt returns no marker and the tail goes unread — the safe direction, covered by two new cases in runner-failure-diagnostics.test.ts. Type names unified: runner/host.ts re-exports IosDeviceReadiness under the name the reader owns, so the derived IosDeviceRunnerReadiness alias is gone and the tests import one name.

Gates at the new head: build, format, check:quick, check:layering, check:gate-manifest, check:command-docs, check:production-exports, apple-runner (528), full unit lane (10,657), and fallow audit --base origin/main clean across the stack's 28 changed files.

Still owed, and not runnable from here: no physical captures. .device-evidence/CHECKLIST.md §#2683 item 4 is rewritten for this round and now records which of three outcomes a real image-down device gives — the build clears it (the outcome that justified removing the gate), the build names no cause and gets the image reason, or the build names its own cause and keeps it. Items 1–6 stay open with the coordinator; the state pairings remain invented-shape.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 120ae34.

The checklist at https://github.com/callstack/agent-device/blob/120ae34/.device-evidence/CHECKLIST.md#L102 still shows section #2683 steps 1-6 as unrun, and runner-startup-failure-fixtures.ts still marks the developerMode: 'disabled' and ddiServicesAvailable: false pairings with provenance 'invented-shape'; the author's latest comment confirms live evidence is still pending. Nothing here shows the real devicectl payload ever spells "disabled" (so device_developer_mode_disabled may be unreachable), and nothing shows the post-reboot triple this round's fix targets. This preflight can refuse every physical-device session start, and its new reason replaces xcodebuild's own hint, so it should not ship without a device behind it. Please run at 120ae34 on a physical iPhone and paste: raw devicectl device info details (deviceProperties.developerModeStatus, ddiServicesAvailable, bootState, tunnelState) on a healthy device and again immediately after reboot/unlock/cable, before any Xcode or devicectl launch; prepare ios-runner --platform ios --json with Developer Mode off, showing details.reason: device_developer_mode_disabled and its hint; and prepare ios-runner --platform ios --json from the image-down state, showing which of the three checklist outcomes occurs, with details.developerDiskImage and the hint compared character-for-character against the devicectl-owned string.

Not blocking, can be taken or left: corroborateRunnerBuildFailureWithDeviceStates keys on the reason value build_failed_unclassified rather than on whether any rule actually matched, so a just-rebooted device with an unavailable image plus a conflicting-settings build error gets republished with the disk-image hint instead of the cache-recovery hint (packages/platform-apple/src/runner/runner-contract.ts#L889, rule at #L443-L455) — could classifyRunnerStartupFailure return whether a rule matched so the invariant becomes "the device speaks only when nothing matched"; deviceStates are only attached inside the build catch, so a warm derived-cache path or an external xctestrun never carries them into a later install/launch failure (packages/platform-apple/src/runner/runner-artifact.ts#L98) — worth attaching them at the session-level catch instead, or documenting that only a cold build publishes this; the new test file declares its own escapeRegExp when four other copies already exist in the repo (packages/platform-apple/src/runner/tests/runner-startup-failure-reasons.test.ts#L51) — assert.ok(hint.includes(CONSTANT)) would avoid the regex entirely; and the rewritten CLI help sentence now has a dangling "or build_failed_unclassified" clause after a sentence break (src/commands/schema/cli-help.ts#L692) that should move back into the enumeration.

Does this delta need its own deviceStates option threaded through RunnerXctestrunBuildOptions plus an exported corroborateRunnerBuildFailureWithDeviceStates, given that startRunnerSessionWithLease already holds deviceStates and already wraps the build in measureRunnerStartupStep — could one catch there attach reason/hint/details.developerDiskImage to whatever the startup threw, without a new build option or exported contract function, and cover the cache-hit and launch failures noted above as a side effect? Moving the enrichment up would mean the fixture suite drives the session-level catch instead of runner-artifact's build catch, or a thin exported enrich step it can call. The earlier point about module placement (eager-closure budgets keeping the helper next to classifyRunnerStartupFailure) is settled and not being reopened.

I ran no tests and no device commands for this round; the red-without-the-fix claims were judged by reading the flipped assertions and the pre-change code, not by mutating it. iOS 17+ CoreDevice mount-on-demand behavior is still inferred, not observed, so the checklist run is what settles whether the post-reboot triple occurs at all. Per follow-up scope I did not re-review the devicectl payload parser, the remedies plumbing, or physical-device-coredevice beyond the observability gate that the earlier finding and the corroboration rule depend on; those were settled at 27f3836. I did not exercise the launch/install failure route end to end, so that finding's impact is read from the call graph, not observed.

CI is green: GitHub reports 21 checks, 0 not passing, at 120ae34. The Smoke Tests simulator failure seen at 27f3836 is gone, and the only simulator-touching change here, the per-command runner.log boundary, is exercised by the unit lane.

Before merge, run CHECKLIST section #2683 steps 1, 3 and 4 on a physical iPhone at 120ae34 and paste the raw payloads and JSON output; fixing the corroboration rule's keying is worth doing alongside that.

@thymikee
thymikee force-pushed the apex/2683-device-readiness-facts branch from 120ae34 to a371ad2 Compare September 20, 2026 14:03
@thymikee

Copy link
Copy Markdown
Member Author

All four items in, rebased onto #2688 bcfed35b4c (on main fc59620932). New head: a371ad284f.

The device now keys on "did a row match", not on the unclassified literal. You had the failure exactly right: build_failed_unclassified is what no row spoke publishes and what a row that deliberately declines to name a cause publishes, so keying on the reason reads a row's silence as an empty room. classifyRunnerStartupFailure returns { reason, hint, matched } (runner-contract.ts), and the catch that classifies carries its own answer onward as details.startupRuleMatched — necessary rather than tidy, because the wrapper buries the tool's text one level deeper than the rows read (details stays out of reach on purpose since #2680), so re-classifying a wrapper would report "nothing matched" for a failure a row had just looked at. conflicting-settings-on-device-with-image-down pins the case you described: just-rebooted phone, image down, conflicting-settings error → reason stays build_failed_unclassified, hint stays cache-recovery, developerDiskImage: "unavailable" still rides along as a detail. Remove the flag and that fixture goes red; it is a fixture-driven case, not a comment.

The enrichment moved up to the session's startup catch. startRunnerSessionWithLease wraps artifact → port → env → redirect → launch in one try and hands whatever escaped to enrichRunnerStartupFailureWithDeviceStates, which is exported and thin. RunnerXctestrunBuildOptions.deviceStates is gone and runner-artifact.ts is back to claiming only what the tool text proves. That covers the two paths the build catch could never reach: warm derived cache (build skipped) and an external xctestrun that fails at install/launch — runner-session-readiness.test.ts now drives the real ensureRunnerSession with a fake devicectl payload for the cold build, the warm-cache launch, and a healthy-image device that must claim nothing for someone else's failure. Making the enricher a pass-through takes all three plus the fixture cases down; that is the red-without, and a371ad284f is the green-with.

The enricher never overwrites: a failure that already carries a reason keeps it, and a non-AppError comes back untouched so a cancellation keeps its identity rather than becoming a signing story.

escapeRegExp deleted. HINT_FOR_REASON is a map of literal phrases now and the assertion is hint.includes(...), which is also the more honest claim for a remedy owned by core/devicectl.ts — the fifth copy of that helper was not going to earn its keep next to the four already in the repo.

Help clause fixed. build_failed_unclassified is back inside the enumeration at src/commands/schema/cli-help.ts:692 instead of dangling after a sentence break, and the two device reasons got their own closing sentence.

Gates at the new head: build, format, check:quick, check:layering, check:gate-manifest, check:command-docs, check:production-exports, apple-runner (533), full unit lane (10,665), and fallow audit --base origin/main clean across 28 changed files.

Phase B is still yours and still owed at this head: the three §#2680 prepare runs, and §#2683 steps 1, 3 and 4 — step 4 including the post-reboot reading (which of the three outcomes a real image-down device gives) and the dev-mode-off pairing. Nothing moved to captured, and xcodeVersion stays unobserved everywhere.

@thymikee

Copy link
Copy Markdown
Member Author

This is a follow-up to the earlier findings at 120ae34 (#2690 (comment)), reviewed here at a371ad2.

speaks in packages/platform-apple/src/runner/runner-contract.ts#L930 is true whenever an AppError has no claimed reason and no matched rule row. A build that the host itself kills at its own deadline (host-kit exec.ts#L585, COMMAND_FAILED "xcodebuild timed out after Nms", details.timeoutMs, matched by no row) goes through the build catch as build_failed_unclassified/matched:false, and the session catch republishes it as device_developer_disk_image_unavailable with the mount hint whenever the phone reports ddiServicesAvailable:false — exactly the just-rebooted state this PR targets. The delta widens this window further: the catch now also spans the port, xctestrun-env, and launch steps, which can time out the same way. So on a physical iPhone whose first build after a reboot exceeds buildTimeoutMs, a consumer branching on details.reason gets sent to mount a developer disk image for a failure our own deadline caused, and the mount hint replaces cache-recovery advice. Can the enrichment state the full invariant: the device may name a cause only when no rule row matched AND our own deadline did not terminate the command? host-kit already carries that fact (isCommandTimeoutError, packages/host-kit/src/internal/exec.ts#L600, details.timeoutMs) — can it be threaded through the build catch the same way startupRuleMatched is, instead of adding another local guard?

Not blocking: the startup try in runner-session.ts#L299 only spans launchRunnerProcess (spawn), so connect/early-exit failures thrown later by waitForRunner fall outside the catch and the new test only proves the spawn case, not the install-failure case; the rebuilt AppError at runner-contract.ts#L934 drops error.cause (no fourth argument passed, see packages/kernel/src/errors.ts#L213 and normalizeError at :326-331); the JSDoc block at runner-contract.ts#L946-950 sits above the wrong field and claimedStartupFailureReason at :964 has none; and startupRuleMatched at runner-artifact.ts#L533 is internal plumbing that ships verbatim in the CLI --json and MCP error payloads without being documented or stripped — these can be taken or left.

Live evidence per .device-evidence/CHECKLIST.md §#2683 items 1, 3 and 4 is still missing on a physical iPhone at a371ad2; fixtures remain provenance 'invented-shape' and the author has confirmed evidence is pending. Since the enrichment now lives in the session startup catch, the image-down run needs to actually reach that catch. What's needed: raw devicectl device info details (developerModeStatus, ddiServicesAvailable, bootState, tunnelState) on a healthy device and again right after reboot/unlock/cable, before any Xcode or devicectl launch; prepare ios-runner --platform ios --json with Developer Mode off, showing details.reason device_developer_mode_disabled and its hint; and the same command from the image-down state, showing which startup step failed, details.reason, details.developerDiskImage, and the hint compared character-for-character against the devicectl-owned string.

No tests and no device commands were run for this review; the read-without-the-fix judgment came from reading the three new session-readiness tests against the pre-change build catch, not from mutating the code. Whether f1 is reachable in practice — a build timeout coinciding with ddiServicesAvailable:false while the tunnel is connected and boot state is booted — is read from the code path, not observed on a device. The devicectl payload parser, remedies plumbing, runner-device-readiness, and the log-offset marker internals were not re-reviewed; they settled at 27f3836/120ae34 and are unchanged by this delta. It's also unclear which startup step a real DDI-down device actually fails at, which decides how much the launch-step widening in the non-blocking note matters in practice.

Both non-passing checks are Smoke Tests, queued at a371ad2 with no failure excerpt yet, so there's nothing to attribute — but this delta touches the iOS session-startup route that lane exercises, so the result is worth reading once it lands.

The device-speaks window for host-killed timeout failures needs to close per the first finding, and then CHECKLIST §#2683 items 1, 3 and 4 need a run on a physical iPhone at the shipped head, with the raw payloads and the image-down JSON pasted here, before this is ready to merge.

@thymikee
thymikee force-pushed the apex/2683-device-readiness-facts branch from a371ad2 to 52ab61e Compare September 20, 2026 16:56
thymikee added a commit that referenced this pull request Sep 20, 2026
A build the host killed at its own `buildTimeoutMs` arrives with no rule matched, and on a phone that
happens to report its developer image down the enrichment answered with the image: a build that never
finished cannot have been refused for want of device support, and the caller was sent to mount an
image while their real problem was a 15-minute budget (#2690 review). The device now speaks only where
nothing matched AND the host's deadline did not end the command. The build catch threads that fact the
way it already threads `startupRuleMatched` — its wrapper buries the exec error too deep to inspect —
and a failure that never passed through a catch is read with host-kit's `isCommandTimeoutError`, so the
install and launch steps are covered by the exec's own `timeoutMs`.

Two things the same catch was doing to the error it rebuilt: the cause is now passed through, so the
reader of a daemon log still reaches the command that died, and the plumbing keys — whether a row
matched, whether the deadline hit — are internal and stripped with the hoisted diagnostic meta, because
a caller already holds the verdict they produced rather than the machinery that reached it.
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto apex/2680-typed-build-failure-reasons @ 15808ae228. New head: 52ab61eb42 (was a371ad284f).

Blocking — the host-timeout window is closed. The device now names a cause only where no rule row matched and the host's own deadline did not end the command. enrichRunnerStartupFailureWithDeviceStates gained startupFailureHostDeadlineHit, and the build catch threads startupHostDeadlineHit: isCommandTimeoutError(appErr) into the same details bag it already carries startupRuleMatched in — that wrapper buries the exec error a level too deep to inspect, so the fact has to travel with the verdict. A failure that never passed through a catch is answered by host-kit's isCommandTimeoutError directly, which covers the install and launch steps through the exec's own timeoutMs detail. No new sniffing, no local ad-hoc guard: error.code === 'COMMAND_FAILED' && typeof details.timeoutMs === 'number' is host-kit's claim, read through the runner host port (runner/host.ts), not a direct host-kit/command value import.

The fixture is host-killed-build-on-device-with-image-down: a carrier: 'host-timeout' exec error (xcodebuild timed out after 900000ms, timeoutMs in details, partial log) on a phone reporting its image down. It stays build_failed_unclassified with the cache-recovery hint and carries details.developerDiskImage as state only. Red three ways — gate removed, threaded fact removed, and (for the fallback test) the direct-reading branch removed — green as shipped. The 15-minute budget and the partial log are ours, so the entry says invented-shape / xcodeVersion: 'unobserved'.

error.cause survives the enrichment. The rebuilt AppError passes the cause through as its fourth argument, so the reader of a daemon log still reaches the command that actually died. Asserted by identity in the device speaking for a failure keeps the error that caused it; red without the pass-through.

startupRuleMatched is internal and off the wire. Both plumbing keys — startupRuleMatched and startupHostDeadlineHit — are now listed as INTERNAL_PLUMBING_DETAIL_KEYS in normalizeError's stripDiagnosticMeta, next to the hoisted diagnostic meta, with a comment saying why: they describe our machinery, and the caller already holds the verdict they produced as reason and hint. assertFailureEnvelope asserts both are absent from the rendered envelope, so every fixture pays for the strip; dropping the delete turns ~10 of them red.

On the predicted runtime.ts conflict: it never materialised. This stack does not touch packages/platform-apple/src/runtime.ts at all — device readiness lives in core/ and runner/ — and #2682's appstate/targetActivation disclosure is intact at this head (runtime.ts:106). The only file this PR shares with the merged work is src/commands/schema/cli-help.ts, one line. The rebase conflicts were all in .device-evidence/CHECKLIST.md, resolved the same way as #2688: main keeps that file, this stack's sheet is .device-evidence/CHECKLIST-runner-failures.md.

Gates at this head: check:quick, check:layering, check:production-exports clean; vitest --project apple-runner 54 files / 543 tests; test:unit 1363 files / 10767 tests.

Still owed: the physical-device captures in .device-evidence/CHECKLIST-runner-failures.md are coordinator Phase B on the connected iPhone — the device-report rows and the corroborated pairing have no hardware evidence at this head.

@thymikee

Copy link
Copy Markdown
Member Author

Follow-up on a371ad2 (#2690 (comment)), now at 52ab61e. The host-deadline invariant is correctly closed — a host-deadline kill can no longer be blamed on the device. The device-facing evidence gap is what remains.

.device-evidence/CHECKLIST-runner-failures.md is still an instruction list with no pasted results. Fourteen startup fixtures remain provenance invented-shape, and the only captured devicectl payload is the healthy one (developerModeStatus enabled, ddiServicesAvailable true). No run shows devicectl reporting a disabled spelling, so the single refusal preflightIosRunnerDeviceReadiness can raise is unproven on a real phone — and an unrecognised spelling falls through to unknown with the refusal never firing. On a physical iPhone at this head, please paste the raw devicectl device info details output with Developer Mode off, showing the literal developerModeStatus string; then prepare ios-runner --platform ios --json from that same state, showing details.reason as device_developer_mode_disabled with its hint; plus one prepare ios-runner --json from an image-down state (post-reboot, unlocked, cabled) showing details.developerDiskImage and which hint gets published.

Not blocking, and take it or leave it: the kernel now enumerates two apple-runner detail keys through INTERNAL_PLUMBING_DETAIL_KEYS instead of reading them off error.cause, and eight tracked replay artifacts were re-committed with only a new timestamp and tmp path. On the first of those — does the build catch in runner-artifact.ts need to keep dropping the exec error into a separate details channel, or could it pass appErr as the AppError's cause so runner-contract.ts classifies and timeout-tests the cause directly? That would remove the kernel-side key list and the envelope assertions that police it. What would have to change first is that the build catch owns the cause it currently discards; classifyRunnerStartupFailure reads only the wrapper today.

On CI: Smoke Tests was still running at 52ab61e, so no job log attaches to this commit. The diff does overlap the route that lane exercises (runner startup build and launch, kernel error normalization), though enrichRunnerStartupFailureWithDeviceStates stays inert on simulators because the preflight returns undefined for kind !== 'device'.

Two limits on this pass: I did not run the unit suite, so the pre-fix redness of the new fixture is argued from the classifier rows and the prior enrichment rather than from a run; and whether xcodebuild's own install step can produce a host-deadline error that reaches the enriched catch needs a device, since that spawn is background and its failures surface in waitForRunner.

Next blocker: the live physical-iPhone evidence above.

@thymikee

Copy link
Copy Markdown
Member Author

Phase B — #2690 captured on a real iPhone (Developer Mode OFF)

Head 52ab61eb42, device thymikee-iphone (iPhone 17 Pro, iOS 27.0, build 24A437), Xcode 26.2/17C52. Cabled, bootState: booted, tunnelState: connected.

This closes the gap that "the only captured devicectl payload is the healthy one" and that the single refusal preflightIosRunnerDeviceReadiness can raise was "unproven on a real phone".

1. Raw devicectl device info details with Developer Mode off — literal string

$ xcrun devicectl device info details --device <UDID> --json-output out.json
WARNING: Unable to retrieve complete information for this device. The best available information will be returned.
         Error: Failed to acquire assertion
"deviceProperties": {
  "developerModeStatus": "disabled",
  "ddiServicesAvailable": true
}

The recognised spelling is therefore real device output: lowercase "disabled". (Human-readable rendering: Developer Mode Status: Disabled.)

2. prepare ios-runner from that same state — refusal fires with the typed reason

{
  "success": false,
  "error": {
    "code": "COMMAND_FAILED",
    "details": {
      "reason": "device_developer_mode_disabled",
      "deviceReadiness": {
        "deviceId": "<udid>",
        "developerMode": "disabled",
        "developerDiskImage": "available"
      }
    },
    "hint": "Enable Developer Mode on the iOS device (Settings > Privacy & Security > Developer Mode), restart it when prompted, unlock it, then retry."
  }
}

details.reason is device_developer_mode_disabled, the refusal publishes, and details.deviceReadiness carries the device-sourced facts. No fall-through to unknown.

3. Order-of-operations finding worth knowing (adoption precedes readiness)

The stale detached runner had to be cleared before this refusal was reachable: createRunnerSession measures adopt_detached_runner first (runner-session.ts:176) and verify_developer_mode only afterwards (runner-session.ts:198). A live, already-started runner is therefore adopted without a readiness re-check, so with a retained lease this command would have returned success instead of the refusal. Removing the lease made the refusal fire. (Defensible — Developer Mode was on when that runner started — but the ordering is worth stating explicitly, since it is how the first attempt at this capture silently succeeded.)

4. Image-down (DDI) state — captured at the device layer; reason/hint publication not reachable, with the cause located

Watched devicectl continuously across two reboots. The image-down state is real and captured (2690-raw-devicectl-ddi-down.json, 2690-raw-devicectl-ddi-down-2.json):

"deviceProperties": { "developerModeStatus": "enabled", "ddiServicesAvailable": false }

A prepare fired inside a verified target window (ddi=false, boot=booted, tunnel=disconnected, ~5 s budget) returned:

"message": "Runner did not accept connection (xcodebuild exited early)",
"details": { "port": 59632, "xcodebuild": { "exitCode": 70, ... },
             "reason": "IOS_RUNNER_CONNECT_TIMEOUT" }

No developerDiskImage, and no device_developer_disk_image_unavailable. Two independent reasons, both located:

  1. details.developerDiskImage is never published on this path. The only call site that attaches device states is the launch_xcodebuild catch (runner-session.ts:300 → enrichRunnerStartupFailureWithDeviceStates, runner-contract.ts:932). Here launch_xcodebuild did not throw — xcodebuild exited after launch (exit 70), and the failure is raised by the connect/early-exit step, which is outside that catch. So the enrichment never runs. Coverage gap, not a misclassification: if you want the DDI fact to travel on connect-stage failures, it needs a second attachment site (or the early-exit path has to route through the same catch).

  2. The unlocked image-down state does not exist on iOS 27. ddiServicesAvailable was false only while the phone was locked, held that way for the whole locked window, and flipped to true within seconds of unlocking — unlocking is what mounts the image. So the review's "post-reboot, unlocked, cabled, image-down" combination is not producible on this device.

5. The host-deadline invariant is proven against a real device fault

During a ~2-minute image-down window, prepare on a 25 s budget failed as details: { phase: "runner_rebuild", reason: "prepare_deadline_expired" } — a host-killed rebuild on a device that genuinely had a DDI problem — and it published no device reason and no readiness facts. Across every sample of both image-down windows there was no developer_disk_image_unavailable misattribution. A host timeout is never blamed on the device, which is the behaviour the review asked to close.

@thymikee

Copy link
Copy Markdown
Member Author

The Developer Mode half is now proven end to end at 52ab61e. Real devicectl output spells the status disabled, prepare ios-runner publishes device_developer_mode_disabled with its hint and its readiness facts, and across both image-down windows a host timeout was never blamed on the device. That closes everything I asked for on that side, and your finding that an unlocked image-down state does not exist on iOS 27 refutes the scenario I asked for rather than leaving it open, which is the better outcome.

The disk image half is the one thing left, and your own run is what shows it. Inside the verified image-down window prepare failed as IOS_RUNNER_CONNECT_TIMEOUT with no developerDiskImage and no device_developer_disk_image_unavailable, because the only site that attaches device states is the launch_xcodebuild catch and the connect/early-exit step never enters it. Reading the disk image state is half of what this change promises, so could the early-exit path route through the same catch, so the fact travels on the failure a locked phone actually produces? If you would rather leave it, then that reason and the developerDiskImage detail have no observed path that publishes them, and it is worth saying so where they are declared.

Your note that adoption runs before the readiness check is worth keeping in the PR body too — it explains why a retained lease can return success where a fresh start refuses. I am holding ready-for-human on the disk image question alone; everything else here is ready.

@thymikee
thymikee force-pushed the apex/2683-device-readiness-facts branch from 52ab61e to 8951f93 Compare September 21, 2026 06:57
thymikee added a commit that referenced this pull request Sep 21, 2026
A build the host killed at its own `buildTimeoutMs` arrives with no rule matched, and on a phone that
happens to report its developer image down the enrichment answered with the image: a build that never
finished cannot have been refused for want of device support, and the caller was sent to mount an
image while their real problem was a 15-minute budget (#2690 review). The device now speaks only where
nothing matched AND the host's deadline did not end the command. The build catch threads that fact the
way it already threads `startupRuleMatched` — its wrapper buries the exec error too deep to inspect —
and a failure that never passed through a catch is read with host-kit's `isCommandTimeoutError`, so the
install and launch steps are covered by the exec's own `timeoutMs`.

Two things the same catch was doing to the error it rebuilt: the cause is now passed through, so the
reader of a daemon log still reaches the command that died, and the plumbing keys — whether a row
matched, whether the deadline hit — are internal and stripped with the hoisted diagnostic meta, because
a caller already holds the verdict they produced rather than the machinery that reached it.
@thymikee

Copy link
Copy Markdown
Member Author

The disk-image question is implemented at 8951f93496 — taking your preferred option, so the early-exit path now routes through the same enrichment.

enrichRunnerStartupFailureWithDeviceStates had exactly one call site, the launch_xcodebuild catch. The failure a locked phone actually produces is raised by the connect/early-exit step, so it never reached that catch. The facts the preflight read before the build now ride the session as startupDeviceStates, and buildRunnerEarlyExitError runs its result through the same enrichment function.

Behaviour is deliberately asymmetric, and that asymmetry is the invariant you asked for:

  • The failure already proved a cause (IOS_RUNNER_CONNECT_TIMEOUT, from the tool's own output), so claimedStartupFailureReason is set and the device is not allowed to overwrite that reason or swap the hint beside it. It contributes the fact only.
  • A failure that proved nothing still gets device_developer_disk_image_unavailable exactly as before, and still refuses to speak when the host's own deadline ended the run.

Three tests in runner-early-exit-diagnosis.test.ts pin it: the fact travels; a claimed cause keeps its reason and hint; a session that never probed the device publishes no developerDiskImage key at all (an adopted session has no startupDeviceStates, so it cannot invent one).

So the reason and the detail now have an observed publishing path rather than a declared one, and it is the path the hardware run actually walked. Your other note — that adoption precedes the readiness check, which is why a retained lease can return success where a fresh start refuses — is now in the PR body alongside the capture that showed it.

Local gates: check:quick, check:layering, apple-runner project (546 tests) green.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 8951f93. The early-exit failure now carries the device facts, and a claimed cause keeps its reason and hint. Thank you, that closes the route I asked about at 52ab61e.

Is the other connect-stage failure covered too? When the xcodebuild child is still alive at the connect deadline, sendRunnerCommand throws buildRunnerConnectError (runner-contract.ts#L808). That path does not call enrichRunnerStartupFailureWithDeviceStates, although session and its startupDeviceStates are in scope (runner-startup-transport.ts#L126). If a locked phone can reach that path, it still reports "Runner did not accept connection" with no developerDiskImage. One wrapper in runner-startup-transport that enriches both connect-stage throws, plus a test for the alive-child connect timeout, would close it.

The checklist (CHECKLIST-runner-failures.md#L76) records the pre-fix image-down run and then says the connect failure now routes through the enrichment. No run at this head shows that yet. Could you rerun prepare ios-runner --platform ios --json on the locked, image-down iPhone at the fixed head and paste the error JSON? It should show details.reason still IOS_RUNNER_CONNECT_TIMEOUT and details.developerDiskImage: "unavailable".

A small note: on this route classifyBootFailure always reads the early-exit message as IOS_RUNNER_CONNECT_TIMEOUT, so device_developer_disk_image_unavailable is published only from the build catch, and one sentence in the RUNNER_DEVICE_READINESS_FAILURE_REASONS doc could say so.

CI is green and there are no conflicts. I am holding ready-for-human on the connect-stage question and the live run.

@thymikee

Copy link
Copy Markdown
Member Author

Addressed at eaf411e.

buildRunnerConnectError now takes the device states and runs its error through enrichRunnerStartupFailureWithDeviceStates, the same as buildRunnerEarlyExitError. On a physical device waitForRunner ends in exactly two throws (runner-startup-transport.ts#L123-L132): the early exit and the alive-child connect timeout. Both now carry details.developerDiskImage. The early-exit error built inside the retry loop is caught there and rebuilt at the final throw, so it needs nothing extra. A connect-stage message always classifies to a cause of its own, so the device adds the fact but never replaces the reason or the hint.

New test: waitForRunner carries the disk-image state when the runner is still alive at the connect deadline in runner-startup-transport.test.ts. It fails when deviceStates: session?.startupDeviceStates is dropped from waitForRunner.

The RUNNER_DEVICE_READINESS_FAILURE_REASONS doc now says that device_developer_disk_image_unavailable is published only from the startup build catch.

Validation: pnpm check:affected --run passes at eaf411e (59 checks), and pnpm typecheck passes.

Still open: the live prepare ios-runner --platform ios --json on a locked, image-down iPhone at this head. It needs the phone rebooted and kept locked, and it has not run yet.

@thymikee

Copy link
Copy Markdown
Member Author

Live run at eaf411e on a cabled iPhone 17 Pro (iOS 27.0, 24A437), rebooted and held locked.

The healthy run first: with the phone unlocked, prepare ios-runner --platform ios --device thymikee-iphone --json succeeded in 36 s (cache exact, connect 1196 ms).

After the reboot, still locked, the same command failed as before:

{
  "code": "COMMAND_FAILED",
  "message": "Runner did not accept connection (xcodebuild exited early)",
  "details": { "reason": "IOS_RUNNER_CONNECT_TIMEOUT", "xcodebuild": { "exitCode": 70 } }
}

There is no developerDiskImage, and that is correct, not a missed route. Every devicectl device info details read from 10:56 to 10:58 UTC, before and after the run, said tunnelState: "unavailable", bootState: "booted", ddiServicesAvailable: false. readDeviceReadiness treats an image read without a connected tunnel as unobservable and returns available: false, so the session has no device states to carry. Before the first unlock the tunnel never comes up, so on iOS 27 a locked phone never publishes its image state. The earlier image-down run failed the same way for the same reason, not because of the call site.

So both connect-stage failures now carry the device states whenever the preflight could observe them, and the unit tests pin that. But no hardware state I can reach produces developerDiskImage: "unavailable" on a connect failure. I corrected the checklist in 1372a69 to say so.

stack merge was automatically disabled September 21, 2026 11:13

Pull Request is not mergeable

stack merge was automatically disabled September 21, 2026 11:14

Pull Request is not mergeable

Base automatically changed from apex/2680-typed-build-failure-reasons to main September 21, 2026 11:19
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at f5526ba. The alive-child connect timeout now carries the device states too: waitForRunner passes session.startupDeviceStates into buildRunnerConnectError, which runs the same enrichment as the early exit (runner-startup-transport.ts#L126-L132), and the new test fails without it. Thank you for the locked-phone run at eaf411e and the checklist fix in 1372a69. With the tunnel down there are no states to carry, so the missing developerDiskImage on that run is correct. The one branch that adds the field (tunnel up, image unavailable, connect timeout) is covered by the unit test only, which is fine since you could not hold the phone in that state.

A small question, not blocking: waitForRunner also runs for ready sessions (runner-session.ts#L934 and #L969), and startupDeviceStates is never cleared, so could a later connect timeout report the disk-image state read before the build? Passing the states only while session.state === 'starting' would avoid that.

All checks pass. The PR has merge conflicts with main after #2688 was squash-merged, so it needs a rebase before it can get ready-for-human.

thymikee and others added 16 commits September 21, 2026 13:44
`xcrun devicectl device info details` has always carried two separate answers about a device —
`deviceProperties.developerModeStatus` for the Settings toggle and `ddiServicesAvailable` for the
developer disk image — and #2682 read the payload for the tunnel only, so both states stayed invisible
to the runner.

`core/ios-device-readiness.ts` now reads them apart and publishes them through the physical-device
control facet, which is the seam the runner already reaches devices through. The parser copies the
tool's values without interpreting them; a state it does not recognise stays `unknown` rather than
becoming either permission or accusation. `resolveCoreDeviceTunnelIp` now shares that one reader
instead of re-running the command.

The two reasons are declared as a subset of the startup vocabulary, apart from the rule-driven
members, because no amount of tool text establishes them. The captured payload is committed with its
device identifiers, hostnames and capture path masked.
One `devicectl` hint covered both "developer mode is disabled" and "developer disk image", and it
named Developer Mode. That pairing runs one way only: a phone with the toggle off cannot mount the
image either. The reverse is common — a device whose toggle is on, waiting for Xcode to finish
installing device support — and people were sent to a Settings pane that was already correct while
the actual wait went unmentioned.

The two complaints are answered apart now. When the output names both, the toggle still wins, since
it is the one that explains the other. #2683 adds the device's own reading of both states, which is
what lets the tool-output path stop guessing which one it is looking at.
One `runner.log` serves every command sent to a device and is never truncated between them, and a
failing response was classified from the last 64 KB of it. A crash line an earlier command wrote
therefore explained whatever failed next: two commands after an app died in AXRuntime, an unrelated
timeout was reported as `target_app_axruntime_coretext_crash`, sending the reader to hunt a crash that
had already been dealt with.

A command now marks where the log had reached before anything is sent, including before its own
readiness preflight, and only bytes past that mark are read. A log that has been rewritten behind the
mark is shorter than it and is not read at all — that is not evidence about this command.
A phone that refuses to host development tooling fails opaquely today: the build cannot install, or
the runner never reaches its port, and the caller reads a build log for a phone problem. The device
already states the reason, so startup now asks it — after the host's own `DevToolsSecurity` probe and
for physical iPhones only — and publishes `device_developer_mode_disabled` or
`device_developer_disk_image_unavailable` with the hint beside the rule that names it.

The toggle is weighed first because a phone with it off cannot serve its developer disk image either;
the reverse never holds, and an image that is down on a phone with the toggle on is never restated as
a toggle problem. A device that could not answer is left alone: `available: false` carries no verdict,
and inventing one from a missing read turns an unplugged cable into a claim about someone's Settings.

The probe is loaded at its step rather than imported at the top, for the same reason as the host probe
above it: the runner subtree sits in the eager closure of seven Apple facades, and a check only a
device ever needs has no business being evaluated to answer a simulator request.
…ner port

Two gates decided this shape, and both are worth recording because neither is visible from the code:

- `scripts/layering` R9/R10: a type-only import of `runner/runner-contract.ts` from `core/physical-device-control.ts` closed a 13-file type-level cycle across the runner subtree, because pulling that module pulls the whole runner contract graph. The fact types now live with the module that reads the payload, and `runner/host.ts` mirrors the shape structurally — which is what that file prescribes for a host port anyway, with the composition-root assignment as the conformance check.
- `eager-closure-budgets`: a separate `core/ios-device-readiness.ts` added one module to the eager closure of six Apple facades, because the physical-device control facet reaches it statically. The reader now sits beside the payload parse it consumes, where it adds nothing to any closure.

Behaviour is unchanged: same two states, same unreadable shape, same tests carried over.
…it lane

A recorded tool response checked in under a package (`…/src/**/__tests__/fixtures/*.json`, or a
`fixtures/` dir beside the module that parses it) has no derivable owner: nothing builds a `.json` and
no `.ts` sibling names it, so editing one failed the affected gate open to the full check set. That
punished adding evidence — the capture in #2683 tripped it — while the pre-existing
`snapshot-source/fixtures/wire-vocabulary.json` has been sitting in the same gap.

The rule names the one suite that reads such a file. Root-level and `test/` fixtures still fail open
as before, which is right: nothing outside a package says who consumes them.

Editing the selector model fails the gate open by design (`selector-owning`), so this change cannot
narrow its own selection; the full set runs for it.
…roduced it

Review round on the readiness work (#2683):

- Drain the runner's queued log appends before drawing a log boundary, so bytes an
  earlier command has not finished writing cannot be read as this command's. The
  boundary is now one object (`RunnerLogAttempt`) with no log-path-only shape, and
  every call site reads through it.
- Treat an uncorroborated `ddiServicesAvailable: false` as unreadable rather than as
  a developer-disk-image obstacle: the services only answer while the tunnel is up
  and the phone is booted. A disabled toggle keeps its answer regardless, since it
  already explains the image.
- Publish each remedy once: the device report carries the wording `core/devicectl.ts`
  owns, and the preflight reads it instead of wordings its own.
- Bound the readiness probe by the startup budget and its signal, and abort after the
  read if the caller gave up.
- Probe the device before the host's developer-tools setting, so a device and a Mac
  that are both wrong publish the reason the caller can act on.
- Mask the identity fields in the committed device payload and give the mask a test.
…ir modules

The Fallow audit that CI runs on changed files was red on this branch:

- Collapse the two payload shapes CoreDevice has shipped into one pair of sections
  before any field is read, so the parser stops spelling the nested fallback out per
  field and drops below the complexity threshold. Direct values still win.
- Report a reported field through one helper that omits it when absent, keeping
  "the device said nothing" distinct from "the device said no".
- Stop exporting `readIosDeviceDetails` and `nameIosDeviceReadinessObstacle`: each
  has one caller, in its own module, and a caller that needs a verdict reads
  `readIosDeviceReadiness` or the preflight that publishes the reason.
- Move the build-fixture envelope assertions out of the test body into named
  helpers so the recorded shapes are asserted through the same path.
… host port

#2689 rewrote the runner host port to derive its types from host-kit and the owning
core modules instead of mirroring them, which left this branch naming types that no
longer exist there:

- Derive `IosDeviceRunnerReadiness` from the control port's own return type rather than
  restating the device's states and remedies beside them.
- Take `IosPhysicalDeviceRunnerControl` from `core/physical-device-routing.ts` and
  `ExecResult` from `@agent-device/host-kit/command`, which is where the port now points
  callers.
Developer Mode off was the only device state no later step could change, and it is the only one
that keeps refusing a run before the build. The developer disk image is mounted on demand by
CoreDevice during build and launch since iOS 17, so a phone that has just been rebooted reports it
down while the very next build clears it: refusing there turned a self-clearing state into a failed
run. The preflight now hands the states back, the build carries them, and an unavailable image is
published onto a build that named no cause of its own — beside xcodebuild's own sentence, never in
place of it.

A log flush gained the deadline and abort it was missing, and a refused append is recorded instead
of dropped, because an offset measured over bytes that never landed is not a boundary.
Keying the enrichment on `build_failed_unclassified` read the absence of a row as a positive claim:
a row that deliberately names no cause publishes the same reason, so a just-rebooted phone's disk-image
state would have been published over a conflicting-settings failure. The classifier now reports whether
a row matched, the catch that classifies carries that answer on `details.startupRuleMatched`, and the
enrichment reads it.

The enrichment also moves out of the build catch into the session's startup catch, which already holds
the device's states and sees every step from the artifact to a runner that answers. A warm derived cache
that fails at install and an external xctestrun that never launches now carry
`details.developerDiskImage` too, which the build-catch-only version could not reach.
A build the host killed at its own `buildTimeoutMs` arrives with no rule matched, and on a phone that
happens to report its developer image down the enrichment answered with the image: a build that never
finished cannot have been refused for want of device support, and the caller was sent to mount an
image while their real problem was a 15-minute budget (#2690 review). The device now speaks only where
nothing matched AND the host's deadline did not end the command. The build catch threads that fact the
way it already threads `startupRuleMatched` — its wrapper buries the exec error too deep to inspect —
and a failure that never passed through a catch is read with host-kit's `isCommandTimeoutError`, so the
install and launch steps are covered by the exec's own `timeoutMs`.

Two things the same catch was doing to the error it rebuilt: the cause is now passed through, so the
reader of a daemon log still reaches the command that died, and the plumbing keys — whether a row
matched, whether the deadline hit — are internal and stripped with the hoisted diagnostic meta, because
a caller already holds the verdict they produced rather than the machinery that reached it.
@thymikee
thymikee force-pushed the apex/2683-device-readiness-facts branch from f5526ba to 66bac95 Compare September 21, 2026 11:56
@thymikee
thymikee merged commit ac2cab4 into main Sep 21, 2026
21 checks passed
@thymikee
thymikee deleted the apex/2683-device-readiness-facts branch September 21, 2026 12:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Read Developer Mode and DDI state from the device instead of inferring it from tool output

1 participant