refactor(host): enter one runtime store once per daemon runtime (#3794)

Merged
TH
10h ago
+221-263

Closes #3794
Part of #3747. Second PR: #3798 landed part 1 (configuration reads take the host explicitly). This one is part 2, one entered runtime store.

What changes

Every module that kept per-runtime state declared its own defineRuntimeSlots() store and exposed a createXxxRuntimeState() handle; startDaemonRuntime collected nine of those handles into one context — six constructed by the daemon plus two handed back through DaemonRuntimeDeps.runtimeStates — nested their run calls, and folded the last two in later through RuntimeContext.extend + enter(). That aggregation is now gone: one store per process, one handle entered once per runtime.

  • packages/host-kit/src/runtime-slots.ts — defineRuntimeSlots is replaced by runtimeSlot(key, create, dispose?) and createRuntimeStateHandle(). Modules declare slot keys, they no longer own a store. The entered-store-else-standalone rule is unchanged, so a client process or a unit test still reads its own state without composing a runtime.
  • Six modules lose their handle factory and their per-module store: request-cancel (host-kit), interactor-resolution, provider-device-admission, artifact-tracking, resumable-upload, materialized-path-registry. Their slot keys and disposers stay exactly where they were.
  • startDaemonRuntime creates one handle and runs composition, request dispatch and close inside it, so every read resolves from that runtime's state. DaemonRuntimeDeps.runtimeStates, RuntimeContext, createRuntimeContext and extend are deleted; src/daemon-composition.ts no longer returns platform handles — it calls setAppleRunnerHostEnvironment(host) where it stands, inside the entered handle, which is where createAppleRunnerRuntimeState used to bind it.
  • The Android helper (adb-host) and the Apple runner (runner-owner-state) keep their slot keys and lose their stores. The runner still reaches the store's runtimeSlot through its host port, so no runner/** file imports host-kit (R77). Creating a handle is not a production port member: the only callers were tests, so test-kit gained createTestRuntimeStateHandle (runtime-state.ts), and the runner tests enter their handles through it. runnerRuntimeSlot and helperRuntimeSlot now name runtimeSlot.

Done-when from the issue

  • No module-level handle or runtimeStates aggregation remains. defineRuntimeSlots, createRuntimeContext, RuntimeContext, runtimeStates and .enter() have zero hits in src/, packages/ and test/; the only runtime-slots importers are the modules that declare slots plus the daemon that enters the handle.
  • Two-runtime isolation still passes. The existing tests keep their claims with the handle created explicitly instead of per module: runner-owner-state.test.ts, runner-session-runtime-isolation.test.ts, runner-lease-runtime-host.test.ts (Apple), snapshot-helper-session-runtime-isolation.test.ts (Android helper), daemon-runtime-isolation.test.ts (two real daemons: per-runtime admission, interactor resolution and artifact tracking, and closing one leaves the other serving), plus daemon-composition.test.ts, which now asserts each composition's writes land in the handle it ran inside and that one runtime's host is not the other's. The PNG worker has no slot state to collapse — it is a process-shared worker with holder counting (retainPngWorker), and its holder test still passes; check:affected ran it.
  • Bounded close still refuses to drop state while work is unfinished, observed honestly now: daemon-runtime-bounded-close.test.ts declares a slot on the runtime's own store and asserts its disposer has not run when platformOwners is pending.

Scope notes

  • No gate or lint-rule changes — #3753 owns the process-globals gate, and nothing here touches it.
  • Overlap with the coordinator's parallel work (#3806, #3797): both merged to main while this PR was in review; the PR is rebased onto b5f47e7542 with their changes intact. The single predicted conflict, the close path in packages/daemon/src/server/http/daemon-runtime.ts, was resolved by keeping main's finishDaemonRegistration block with the runtimeContext → runtimeState rename. No other file overlapped.

Evidence

  • pnpm check:affected --run --head ad357823d9 on the exact head, exactCommit=true (receipt run-hpQD6R): format, lint, typecheck, layering, fallow (production-unused exports included), build, vitest-related and daemon-wire-compat all pass. Layering is the gate that proves the port still fronts host-kit for the runner subtree, and the eager-closure budgets were untouched.
  • Live device evidence at this head (round-3 comment has the commands and output): iOS simulator open + snapshot through the Apple runner (backends: {"xctest": 2}, zero apple_runner_host_unbound), and Android snapshot through the helper session (backend: android-helper, helperVersion matches package.json).
  • Review rounds: round 1 removed createRuntimeStateHandle from the runner host port (test-kit owns test support, R83); round 2 removed the getAppleRunnerHostEnvironment facade re-export after the production-unused-exports gate caught it as test-only — the composition test now observes the host binding through resolveRunnerAppBundleId(), a production read the runner's resource cleanup already uses; round 3 added the live runs above, the clean-close disposer assertion, trimmed the narration comments, and answered the provider-bundling scope question. See the round comments below for the full mapping.
  • Focused suites before the gate: platform-apple core+runner (124 files / 1291 tests), packages/daemon + host-kit + src/daemon (551 files / 4087 tests), scripts (55 files / 1285 tests, includes the layering and wire-compat policy tests).
  • Diff is net −42 lines (221 added, 263 removed) across 29 files, in four commits by layer: host-kit store, platform slot declarations, daemon entry, root composition.