feat(exec): hold the device claim for an external command's lifetime

TH
1h ago
+1174-21

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-exclusive claim policy; the registry never claims the wrapper goes through daemon admission.
  • Claim records gain ownerKind: 'wrapper'; the daemon-supersession proof (which reads a daemon.json that can only ever describe daemons) no longer steals live wrapper claims, and claims under a dedicated <stateDir>/exec dir reconcile as owner-process-dead when the wrapper is killed.
  • The typed conflict builder moved to daemon-contracts so exec throws 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 — exec refuses 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 5509d9a7d at 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/lifecycle timeout, then daemon-registration-owner fixture 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 (acquired instead of conflict); 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, full packages/daemon (2770 tests), daemon-contracts, command-registry, exec suites — green. The three POSIX signal cases share one test.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): during exec … -- sleep 120, device status showed live session=exec:sleep and a second exec refused with DEVICE_IN_USE naming session "exec:sleep"; SIGTERM → rc 143 and claims store empty; SIGHUP → rc 129, the child sleep in the wrapper's group was gone, and the claims store was empty once the wrapper exited; after exit a fresh exec succeeded (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