feat(exec): hold the device claim for an external command's lifetime
TH
1h ago+1174+1174+0123456789012345678901234567890123456789-21-21-01234567890123456789
Summary
agent-device exec --platform ios --udid <UDID> -- expo run:ios --device <UDID> holds the host-global device claim for the whole lifetime of an external command, so builds that install through the platform tools directly are fenced against agent-device sessions on the same device. Fixes #3263.
- The claim is taken on the wrapper CLI process's own identity (no daemon route: a build outlasts any request envelope, and owner-liveness proofs already reconcile a dead owner). New honest
wrapper-exclusiveclaim policy; the registry never claims the wrapper goes through daemon admission. - Claim records gain
ownerKind: 'wrapper'; the daemon-supersession proof (which reads adaemon.jsonthat can only ever describe daemons) no longer steals live wrapper claims, and claims under a dedicated<stateDir>/execdir reconcile asowner-process-deadwhen the wrapper is killed. - The typed conflict builder moved to
daemon-contractssoexecthrows exactly the refusal the daemon admission seam returns. - Child runs with stdio inherit in its own process group; every received SIGINT, SIGHUP, SIGQUIT or SIGTERM forwards to the group (the first one owns the exit code) and the wrapper releases the claim only after the child exits — so a closed terminal or dropped SSH stops the build instead of stranding the fence. Exit code propagates (128+signum for signal exits, 127 for a missing command), and release is attempted on every exit path. macOS and Linux hosts only —
execrefuses to start on win32 (UNSUPPORTED_PLATFORM,reason: unsupported-host-platform). - 25 files; docs:
commands.md,sessions.md, README (matching selectors fence the very device the build targets). No scope expansion beyond the claim-store owner-kind fix the wrapper needs.
agent-device exec --platform ios --udid <udid> -- expo run:ios --device <udid>
agent-device exec --platform android --serial emulator-5554 -- adb install app-debug.apk
agent-device device status # session "exec:<command>" holds the device
Validation
Tested at 5509d9a7d214e5684cfd76ec6ce69a66989e4730 (review rounds 1–3, rebased onto 4c1c78696 which includes #3740 and #3741).
pnpm check:affected --run --head 5509d9a7dat the exact commit: all runnable checks passed (receipt.tmp/validation/run-X3P6fp/receipt.json). Two earlier runs at this head each hit one unrelated timing-sensitive test (snapshot-source/lifecycletimeout, thendaemon-registration-ownerfixture timeout) that pass in isolation and on the clean re-run.- Fail-before/pass-after: with the ownerKind supersession guard reverted, the wrapper-steal regression fails (
acquiredinstead ofconflict); with the forwarded-signal list narrowed back to SIGINT/SIGTERM, the real-SIGHUP case fails (claim stranded at a dead pid, child alive). Mutations (no release, no signal code, no forward, drop-second-signal) each fail their test for the named reason. - Unit:
pnpm typecheck,pnpm lint,check:layering,check:doc-prose, help-conformance, command-doc-coverage, fullpackages/daemon(2770 tests), daemon-contracts, command-registry, exec suites — green. The three POSIX signal cases share onetest.skipIf(IS_WINDOWS)condition. - Live simulator at 2665364c4 through the real CLI dispatch (iPhone 17 Pro
ExecIssue3263R3, iOS 27.0, created and deleted for the check; 5509d9a only adds a Windows test skip): duringexec … -- sleep 120,device statusshowedlive session=exec:sleepand a secondexecrefused withDEVICE_IN_USEnamingsession "exec:sleep"; SIGTERM → rc 143 and claims store empty; SIGHUP → rc 129, the childsleepin the wrapper's group was gone, and the claims store was empty once the wrapper exited; after exit a freshexecsucceeded (rc 0). - Not verified: provider-leased cloud targets only hit the local inventory path (never claimable, by design); Windows is refused by policy, not exercised; the child has no controlling terminal, so tools that open
/dev/tty(sudo, credential prompts) may fail — untested; Ctrl-Z stops the wrapper but not the child (job control for a session leader's detached child would need a terminal the child doesn't share).
🤖 Generated with Claude Code