Skip to content

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

Merged
thymikee merged 4 commits into
mainfrom
refactor/one-entered-runtime-store
Oct 19, 2026
Merged

thymikee merged 4 commits into
mainfrom
refactor/one-entered-runtime-store

Conversation

@thymikee

@thymikee thymikee commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

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

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.

@thymikee

Copy link
Copy Markdown
Member Author

Coordinator review at b3510896c0. One item.

A test-only export on a production port. In packages/platform-apple/src/runner/host.ts, the Apple runner host port gained createRuntimeStateHandle (the Pick at :79 and the delegate at :190). The binding is in core/runner-host.ts:124. No production code calls it: every caller is a runner test. The repo rule is no test-only exports, and a port is a production contract. Widening it to satisfy R77 for tests is how test seams leak into the API.

Remove it from the port. Have the runner tests get their handle from test support instead: @agent-device/test-kit, which may import @agent-device/host-kit/runtime-slots, or a fixture that test-kit already provides. If R77 still blocks a runner test from reaching the store that way, fix it at the rule's reported invariant. Don't widen the port. Either way, no allowlist.

Everything else matches #3794: one entered store, runtimeStates and RuntimeContext gone, and the overlap with #3806/#3797 is stated.

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.43 MB 5.43 MB -1.9 kB
Package (unpacked) 5.43 MB 5.43 MB -1.9 kB
Package (download) 1.64 MB 1.64 MB -821 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 31.2 ms 36.5 ms +5.3 ms
CLI --help 58.1 ms 61.5 ms +3.4 ms

@thymikee
thymikee force-pushed the refactor/one-entered-runtime-store branch from b351089 to 37f6d1a Compare October 19, 2026 12:37
@thymikee

Copy link
Copy Markdown
Member Author

Round 1 — the coordinator review, mapped to its commit at head 37f6d1a60e.

Host port carries a test-only symbol. Agreed — a production port must not widen for callers that are only tests, and R77 never needed widening: the rule refuses a direct host-kit import from runner/**, and test-kit is the sanctioned test-support route (runner tests already import @agent-device/test-kit today, e.g. host-setup.ts).

  • Removed from the port: the Pick<typeof HostRuntimeSlots, 'createRuntimeStateHandle'> member, the delegate('createRuntimeStateHandle') line, and the core/runner-host.ts binding are all gone. The port keeps exactly one runtime-slots symbol: runnerRuntimeSlot, which production runner modules call.
  • Test support instead: packages/test-kit/src/runtime-state.ts (new) exports createTestRuntimeStateHandle() (plus the handle type as TestRuntimeStateHandle), named from the test-kit barrel. test-kit is allowed to import @agent-device/host-kit/runtime-slots — it already depends on host-kit and imports host-kit/command and host-kit/process today. No allowlist, no rule change: R77 passes unmodified, and R83 holds because the export is test support in the one package permitted to publish it.
  • Consumers: runner-lease-runtime-host.test.ts and runner-session-runtime-isolation.test.ts now enter their two handles via createTestRuntimeStateHandle() from @agent-device/test-kit. The tests outside the runner subtree (daemon-composition.test.ts, artifact-tracking.test.ts, runner-owner-state.test.ts, the Android helper isolation test) keep importing host-kit directly — R77 scopes to runner/**, so no indirection is owed there.

All of it folds into the platform layer commit 5c55dc043c (fixup + autosquash, four layer commits unchanged).

Verification. Focused: platform-apple core+runner + composition + artifact/helper isolation — 127 files, 1299 tests green. Then pnpm check:affected --run --head 37f6d1a60e on the exact head, exactCommit=true (receipt run-rzvH2V): format, lint, typecheck, layering (incl. R77 and the eager-closure budgets), fallow (incl. production exports), build, vitest-related, daemon-wire-compat — all pass. PR body's port paragraph updated to match. Net diff is now +192/−263 across 29 files.

@thymikee
thymikee force-pushed the refactor/one-entered-runtime-store branch from 37f6d1a to 13cda2a Compare October 19, 2026 13:26
@thymikee

Copy link
Copy Markdown
Member Author

Round 2 — the failed Repo Guards & Compatibility gate (production-unused exports), fixed at head 13cda2a824.

The failure. Fallow's production-export scan flagged getAppleRunnerHostEnvironment (re-export) in packages/platform-apple/src/runner-owner-facade.ts: its only consumer was src/daemon-composition.test.ts. That is the same violation round 1 caught from the other side — a test-only symbol on a production port. The facade re-export entered this branch with the platform layer while the test that consumes it landed in the root layer, so the intermediate commit was itself un-consumed; --production analysis sees no test consumers and refuses. (Locally it had passed only because the pre-fixup gate ran with the stale fallow version.)

The fix — no suppression, the export is gone.

  • 071f897f27 (platforms): drop getAppleRunnerHostEnvironment from the runner-owner facade. Production never consumed the re-export — inside the package, core/runner-host.ts imports the getter directly — so the facade keeps exactly the symbols production reaches: the three setters and the probe type.
  • 13cda2a824 (root): the composition test now observes the host binding through a production read path instead of a test-only getter: resolveRunnerAppBundleId() (exported from runner/operations, already consumed by src/platform-runtime-resource-cleanup.ts:91) takes its env from the runtime's bound host. Each composition's host carries a distinct AGENT_DEVICE_IOS_BUNDLE_ID; the test asserts that inside each entered handle the runner's configuration read answers with that handle's value. Same evidence — composition wrote the host into the entered store, per runtime — taken over a surface production ships.
  • 071f897f27 (platforms): core/runner-owner-state.test.ts now also pins host identity per handle (appleRunnerHost.hostEnvironment() returns each runtime's own bound host), using the port the package owns — no package-boundary export is widened for the assertion.

Verification. check:production-exports ✓ (✓ No issues found), check:layering ✓, typecheck + format ✓, focused suites ✓ (composition 5/5, owner-state 3/3), and pnpm check:affected --run --head 13cda2a824 passed with exactCommit=true (receipt run-rZHKfA, all checks incl. fallow).

@thymikee

Copy link
Copy Markdown
Member Author

The code at 13cda2a looks right to me, but I cannot call it ready yet because there is no live run of the daemon with this change. The PR body cites unit, focused and gate runs only. The one-store design is only proven through the test seams, not through a real daemon request.

Please run a live daemon at 13cda2a with two checks. First, run an iOS simulator command that reaches the Apple runner, such as open plus snapshot, and show it finishes with no apple_runner_host_unbound error. That proves the host binding in the entered handle reaches request dispatch. Second, run an Android snapshot through the helper session. The Smoke Tests iOS and Android lanes at this head, or an equivalent local run with its output, satisfy this.

Checks are still running (Smoke, Repo Guards, Coverage and Integration), and none has failed so far. All four cover this diff, and Repo Guards failed in round 2 on the facade re-export that is now removed. I found no conflicts. I did not run tests myself, so the results in the PR body are as you reported them, and I have not read any smoke output.

Not blocking, and you can take or leave these: the test at packages/daemon/src/server/http/daemon-runtime-bounded-close.test.ts#L211 would still pass if composition ran outside the entered handle, so a clean close of a runtime whose composeDeps declares a slot with a disposer should assert that disposer ran once. Also, some comments in packages/host-kit/src/runtime-slots.ts#L18 and src/daemon-composition.ts narrate history and issue numbers, and could drop those clauses.

Question on scope: the one-store design assumes host-kit/runtime-slots is a single module instance per process, which the bundled build gives the published package. Does any externally published provider plugin bundle its own host-kit copy and declare slots? I found no runtimeSlot use in the provider-* packages.

On the earlier thread from thymikee (#3818 (comment)), the fix is in at this head: createRuntimeStateHandle is gone from the runner port, and the facade no longer re-exports getAppleRunnerHostEnvironment. It does not apply any more, so please resolve it.

Before merge, the live run above must pass and the required checks must go green.

@thymikee

Copy link
Copy Markdown
Member Author

@thymikee This is resolved at head 13cda2a824, so the round-1 thread can be closed:

  • createRuntimeStateHandle is gone from the runner host port (runner/host.ts). It was folded into the platform commit per the review (5c55dc043c → now 071f897f27); the runner port keeps only runnerRuntimeSlot, and tests enter handles through createTestRuntimeStateHandle from @agent-device/test-kit (R83).
  • The follow-up in the same direction also landed: the runner-owner facade no longer re-exports getAppleRunnerHostEnvironment after the production-unused-exports gate caught it; the composition test reads the binding through the production read resolveRunnerAppBundleId() instead (round-2 comment above).

…e process (#3794)

Each module used to declare its own async-local store and expose a handle the daemon had to
aggregate. Modules now declare slot keys on one store: runtimeSlot reads a piece of state from
the store the current runtime entered, and createRuntimeStateHandle makes the one handle a
runtime enters and disposes. The standalone fallback stays, so a client process or a unit test
reads its own state without composing a runtime.

request-cancel drops its handle factory; the runtime's handle owns the slot and its disposer.
…one store (#3794)

The Android helper and the Apple runner no longer own a store. Their modules declare slot keys
on host-kit's single store, and the daemon's entered handle scopes them. The Apple runner keeps
reaching the store through its host port, so no runner module imports host-kit (R77). The host
binding createAppleRunnerRuntimeState used to wrap is the existing setAppleRunnerHostEnvironment
called inside the runtime, so no per-platform handle factory remains.
startDaemonRuntime creates one handle and runs composition, dispatch and close inside it, so
every module slot resolves from that runtime without a per-module handle being composed in.
DaemonRuntimeDeps.runtimeStates, RuntimeContext and its extend aggregation are gone: the store
has nothing left to fold together. Bounded close keeps disposing state only when no work is
unfinished, now observed through a slot of the runtime's own store.
…3794)

Composition runs inside the runtime's handle, so binding this runtime's host for the Apple
runner writes that state directly and the deps no longer carry platform handles back.
@thymikee

Copy link
Copy Markdown
Member Author

At 13cda2a, this branch now conflicts with main after #3806 and #3797 merged. Please rebase it. The code verdict from my earlier comment has not changed: the round-1 thread is resolved, and the one open item is still a live daemon run (iOS open plus snapshot through the Apple runner, and an Android snapshot through the helper session) at the rebased head.

@thymikee
thymikee force-pushed the refactor/one-entered-runtime-store branch from 13cda2a to ad35782 Compare October 19, 2026 14:32
@thymikee

Copy link
Copy Markdown
Member Author

Round 3 — the maintainer review at 13cda2a, mapped at head ad35782.

1. Live daemon evidence (blocking) — done at this head. pnpm build at ad35782, then a real daemon per route, separate --state-dirs under /private/tmp, own simulator + emulator; sessions closed and daemons stopped after.

(a) Apple runner route — iOS simulator open + snapshot:

$ xcrun simctl boot BC54AC11-CD86-4C10-8C4E-5101C773FFEB   # iPhone 17 Pro
$ AGENT_DEVICE_STATE_DIR=/private/tmp/pr3818-live-ios2 node bin/agent-device.mjs open --platform ios --udid BC54AC11…
Opened: default
$ … open com.apple.Preferences --relaunch
Opened: com.apple.Preferences
$ … snapshot -i --json
success: true   nodes: 18   snapshotDiagnostics.stats.backends: {"xctest": 2}
$ grep -rl apple_runner_host_unbound /private/tmp/pr3818-live-ios2 → 0 hits

The snapshot served from the XCTest runner backend (backends: {"xctest": 2}, runner.log shows xcodebuild test-without-building … AGENT_DEVICE_RUNNER_LISTENER_READY), so the host binding composed inside the entered handle reached request dispatch. Zero apple_runner_host_unbound in the entire state dir.

(b) Android helper-session snapshot:

$ pnpm build:android     # helper dist for the source checkout
$ AGENT_DEVICE_STATE_DIR=/private/tmp/pr3818-live-state2 node bin/agent-device.mjs open --platform android --serial emulator-5554
Opened: default
$ … snapshot -i --json
success: true
androidSnapshot: {"backend":"android-helper","helperVersion":"0.21.29-dev","helperApiVersion":"2","helperTransport":"persistent-session","installReason":"current",…}

backend: android-helper and helperVersion matches package.json (0.21.29-dev) — the helper path, not the UIAutomator fallback.

The CI Smoke Tests iOS and Android lanes at 13cda2a also passed (android …403479, ios/macos macos-26 lane), and the same routes now pass again at ad35782 locally as above.

2. Clean-close disposer assertion — applied (0d0935e, daemon layer). daemon-runtime-bounded-close.test.ts gained a clean close disposes the state composition declared in the entered handle: composeDeps declares a slot with a disposer on the runtime's entered store, and the test asserts disposeState was called exactly once when a clean close completes. If composition ran outside the handle, the slot would land in the standalone store and the disposer would never run — the test now fails on that.

3. Comment narration — applied. packages/host-kit/src/runtime-slots.ts and src/daemon-composition.ts keep the constraint each comment owns and drop the history/issue-number clauses (host-kit layer 98a7a51, root layer ad35782). Test-file comments keep only what the assertion cannot show.

4. Scope question — confirmed: no published provider plugin bundles a second host-kit copy that declares slots. The one-store invariant holds because only four production modules call runtimeSlot — host-kit/internal/request-cancel, five daemon modules, the Android helper's three, and Apple's runner-owner-state — and none of them is reachable from a plugin bundle:

  • @agent-device/testmu (packages/provider-testmu, the only externally published provider package; all others are private: true and get alwaysBundled into agent-device itself) bundles @agent-device/* (deps.alwaysBundle: [/^@agent-device\//]), but its source imports only contracts, kernel and provider-webdriver — never host-kit (verified by grep -r "@agent-device/host-kit" packages/provider-testmu/src packages/provider-limrun/src → no hits). Built the real bundle to check: pnpm --filter @agent-device/testmu build → all five emitted dist/*.mjs (incl. plugin.mjs) hold 0 hits for runtimeSlot, AsyncLocalStorage and host-kit — the bundler includes no slot-store module at all. Its only host-kit-adjacent reach is provider-webdriver/src/webdriver-transport.ts:411, a dynamic import of host-kit/diagnostics, which imports nothing from runtime-slots.ts (diagnostics.ts has zero imports). So even a bundled copy could not construct a second slot store; and an unbundled @agent-device/host-kit specifier could not resolve at all — host-kit is private: true, never published.
  • The plugin contract (src/sdk/plugins.ts → @agent-device/plugins/contract) exposes no slot API, so even a hand-built external plugin has no declared way to declare slots. This matches the reviewer's finding of no runtimeSlot use in provider packages — the design holds.

5. Coordinator thread #issuecomment-6267248286 — replied as resolved (#issuecomment-6269101617): createRuntimeStateHandle off the runner port, getAppleRunnerHostEnvironment off the facade.

Rebase. main gained #3806/#3797 while this round was in flight; rebased onto b5f47e7542. The predicted single conflict appeared in daemon-runtime.ts's close path: main's new finishDaemonRegistration block referenced runtimeContext, renamed here to runtimeState; resolved by keeping main's block with the rename.

Gate. pnpm check:affected --run --head ad35782 → all checks pass, exactCommit=true (receipt run-hpQD6R). One note from the first gated run: provider-scenarios/android-tv-remote timed out — attributed with a fresh-worktree bisect to my local untracked android/ime-helper/dist (built for the live check above), which makes a stubbed-ADB apps.open spend ~5s; at plain main in the same dirty worktree it reproduced identically, and with the dist parked both main and this head run the test in <1s. CI and clean worktrees never have that artifact; not a regression from this PR.

@thymikee

Copy link
Copy Markdown
Member Author

The PR is ready at ad35782. The conflict that stopped the earlier review (13cda2a) is gone, and both of the earlier asks are now done: the runner port no longer exposes createRuntimeStateHandle, and the clean-close test now checks that the entered handle disposes its state composition. All 16 checks pass at this head, and Smoke, Integration and Repo Guards cover the daemon runtime entry and the platform slot sites this diff touches.

Not blocking, take or leave: createTestRuntimeStateHandle() in packages/test-kit/src/runtime-state.ts only returns createRuntimeStateHandle(), and the TestRuntimeStateHandle alias adds nothing, yet the two runner tests under packages/platform-apple/src/runner/tests use it to reach an import that R77 forbids there. Either allow that import explicitly in R77 and import the constructor directly, or keep the helper, name it in R77 as the runner-test seam and drop the alias.

The earlier inline threads on the runner port and on the clean-close assertion are fixed at this head, so you can resolve them: #3818 (comment) and #3818 (comment).

The live iOS runner and Android helper runs, and the answer that no provider bundle holds a second host-kit store, come from your report. I did not re-run them and did not read the smoke output. I only confirmed that the provider sources at this head do not use runtimeSlot. I also did not run the test suites, and I judged the new clean-close test by reading runtime-slots.ts.

Nothing else blocks it, so it is ready for a human review.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 19, 2026
@thymikee
thymikee merged commit 56618ac into main Oct 19, 2026
16 checks passed
@thymikee
thymikee deleted the refactor/one-entered-runtime-store branch October 19, 2026 15:45
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-19 15:50 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(host): explicit host for configuration reads and one entered runtime store

1 participant