Skip to content

feat(daemon): run a state-dir boot hook instead of booting a shut-down device - #4153

Draft
janicduplessis wants to merge 2 commits into
callstack:mainfrom
janicduplessis:feat/stim-device-hook
Draft

janicduplessis wants to merge 2 commits into
callstack:mainfrom
janicduplessis:feat/stim-device-hook

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

When a command finds its iOS simulator or Android emulator shut down and the daemon state directory holds boot-hook.json, agent-device runs the hook instead of booting the device, then reads the device state again. A device that is still not booted fails with COMMAND_FAILED, details.reason: boot_hook_left_device_down, and a message that names the hook and quotes its output. There is no fallback boot: a tool that owns device lifecycle, such as Stim, admits devices against capacity limits, and agent-device's own boot bypassed that admission. Without the file, behavior is unchanged.

{ "command": ["stim", "revive", "--json"], "cwd": "/path/to/app" }
  • The hook runs without a shell, in cwd, with AGENT_DEVICE_BOOT_HOOK_PLATFORM (ios/android), AGENT_DEVICE_BOOT_HOOK_DEVICE_ID and AGENT_DEVICE_BOOT_HOOK_DEVICE_NAME, for at most 5 minutes, less under boot/open --timeout and the Android startup budget. A timeout or failed start is reported as a run, so readiness still re-checks. Stderr goes to request diagnostics.
  • The file is read on each boot. An invalid file fails with invalid_boot_hook. Only the daemon's own state directory is read, so a remote client cannot supply a hook.
  • iOS: ensureBootedSimulator reads the hook from a per-runtime slot bound at daemon composition, so every simulator boot (readiness, screenshot retry, settings) goes through it; ensureAppleReady uses the deviceReadiness.bootHook port. Android: ensureEmulatorReady runs it before emulator -avd.
  • Stim writes this file into the per-workspace state directory it already tells agents to use: feat: add stim revive to resume a workspace after an idle stop appandflow/stim#4793.

18 files (8 tests and fixtures, 3 docs). No scope expansion.

Validation

Tested SHA d267ce135. pnpm check:affected --run --head d267ce135 passed, exactCommit: true, 42 checks (receipt .tmp/validation/run-PSVAwB/receipt.json), run on Node 24.18.1. On Node 22.22.2 the same plan stops at docs-links because Astro requires Node >=22.23.4; an earlier run timed out once in provider-integration macos-recording.test.ts under host load near 60 and passed alone. CI owns coverage, provider integration and device lanes.

Live (iOS 27 simulator, paired Stim branch): with Metro idle-stopped by Stim and the simulator shut down (by Stim's idle shutdown in an earlier build's run, by simctl shutdown at this SHA), AGENT_DEVICE_STATE_DIR=<workspace> agent-device open <app> --udid <sim> --debug succeeded in 26 s with bootOccurred: true. Diagnostics: device_boot_hook_start (platform: ios, timeoutMs: 300000), device_boot_hook_exit (exitCode: 0, 22.8 s); Stim restarted Metro and relaunched the app, and agent-device screenshot showed the app's first screen. With an earlier build, after stim stop, open failed with boot_hook_left_device_down quoting Stim's STIM_NO_METRO JSON, and the simulator stayed Shutdown.

Not verified: the Android hook live (unit tests only). Residual: runner startup trusts device.booted before xcodebuild test-without-building, which could boot a shut-down simulator; not traced here.

@vercel

vercel Bot commented Nov 2, 2026

Copy link
Copy Markdown

@janicduplessis is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

@thymikee

thymikee commented Nov 2, 2026

Copy link
Copy Markdown
Member

The code looks right at d267ce1, but the Android hook route has no live proof yet. The Android readiness route now runs the hook before androidEmulator.launch and extends the boot deadline after a hook run. The PR body says this path is unit-tested only, and it is device-facing app-lifecycle code. Nothing shows that a real hook-started AVD is found by findByAvdName/isRunningEmulator, that waitForBoot completes, or that agent-device never spawns its own emulator -avd while a hook is configured. Please run it on a live Android emulator with a boot-hook.json whose command starts the stopped AVD (detached, stdio redirected) and exits. Then run agent-device open <app> --platform android --device <avd> --debug and show device_boot_hook_start (platform: android) and device_boot_hook_exit (exitCode 0), with no agent-device-launched emulator process. Also run once with a hook that exits without starting the AVD, and show details.reason boot_hook_left_device_down.

Not blocking, take or leave: (1) runCmd settles on close, so a hook that backgrounds the device process with inherited stdio (for example emulator -avd X &) keeps the pipes open and the run only ends at the timeout. The rule should be that the hook run ends when its direct child exits, or configuration.md should say the hook must not leave a child holding stdout/stderr. (2) The hook's full stderr is emitted in device_boot_hook_exit, while only the error message is cut to HOOK_OUTPUT_TAIL_CHARS, so please apply the same bound there. (3) After a hook, iOS rejects 'Booting' and skips the bootstatus wait, while Android accepts a running emulator and waits for sys.boot_completed, so please pick one rule for both platforms. (4) The env contract has no headless intent, so boot --headless does not reach the hook; add AGENT_DEVICE_BOOT_HOOK_HEADLESS=1 or document that it does not apply. (5) The per-command readiness route passes no signal or deadline, so the slot hook ignores cancellation for up to 5 minutes. (6) boot_hook_left_device_down and invalid_boot_hook are bare strings; BOOT_TIMEOUT_REASON in packages/contracts/src/device-readiness-runtime.ts is the precedent for exported constants. (7) The hook is built twice, once for the readiness port and once for the slot, each recomputing the state dir; building it once from baseDir would do. (8) In the iOS test, leftDown as never should be a real AppError, the 'runs past its limit' case should use a real 1 s timeout on a sleeping child, and hookTimeoutMs should use host.clock.now() instead of Date.now().

No tests were run for this review, and I could not inspect the local check:affected receipt or the live iOS run. The only check not passing is Vercel, and its bot comment says a Callstack team member must authorize the deployment for this fork contributor, so that looks unrelated to the change. Before merge, the live Android run (success and boot_hook_left_device_down) must be shown.

This branch has not been deployed

No deployments
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.

2 participants