feat(daemon): the runtime owns its state and never the process

TH
1h ago
+1275-550

Summary

Part of #3747, stacked on #3763. Closes #3752.

A daemon runtime no longer assumes it owns the process.

  • Process ownership: startDaemonRuntime never calls process.exit and installs no process.on/once handler. src/daemon.ts owns signals, fatal errors and the exit code. A startup refusal rejects with details.exitCode (daemonStartupExitCode).
  • Shutdown: close() is idempotent: concurrent calls share one promise that resolves to a ShutdownReport (provider releases, runners, sessions, plugins). closed settles whoever closes (idle reap). Provider runtimes dispose exactly once; a startup that fails rolls back what it acquired. daemon stop --json still reports providerReleases.pending.
  • Per-runtime state: provider admission, interactor resolution, artifact/upload tracking, resumable uploads, materialized paths, session-open locks and request cancellation, plus the Apple runner's sessions, locks, recycle ledger and owner state, live in the runtime. Accepted sockets and HTTP handlers keep the runtime explicitly (AsyncResource.bind). The PNG worker is held by every runtime that uses it and stops with the last.
  • Not moved: the Apple runner host binding is a stateless function table (rebinding the same reference is a no-op).

Validation

pnpm check:affected --run --head 0b83711eb1 passed (exact commit). New tests: two runtimes in one process serve, close and keep state independently; idempotent close() and rollback; runner state per runtime; PNG worker refcount.
Live on iPhone 17e simulator (clean daemon, /private/tmp state dir): open, snapshot, close; graceful daemon stop handed the runner off (ios_runner_session_detached, runner kept alive) and a new daemon adopted it with no second xcodebuild; daemon stop --clean and device status left no claims or runners.

🤖 Generated with Claude Code