diff --git a/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts b/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts index b404c18561..371479b4b2 100644 --- a/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts +++ b/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts @@ -60,7 +60,6 @@ export function daemonRuntimeDepsFixture( return async (input) => { const resolved = typeof overrides === 'function' ? overrides(input) : overrides; return Object.freeze({ - runtimeStates: [], ownedProcessRecords: createOwnedProcessRecordStore({ stateDir: input.baseDir, sessionsDir: input.sessionsDir, diff --git a/packages/daemon/src/core/interactor-resolution.ts b/packages/daemon/src/core/interactor-resolution.ts index 217bc6e49f..cdd02f098e 100644 --- a/packages/daemon/src/core/interactor-resolution.ts +++ b/packages/daemon/src/core/interactor-resolution.ts @@ -1,4 +1,4 @@ -import { defineRuntimeSlots, type RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; +import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; @@ -30,17 +30,12 @@ const NO_INTERACTOR_RESOLUTION: InteractorResolution = { type InteractorResolutionState = { resolution: InteractorResolution }; -const interactorResolutionRuntime = defineRuntimeSlots(); const INTERACTOR_RESOLUTION_SLOT = Symbol('interactorResolution'); const interactorResolutionState = (): InteractorResolutionState => - interactorResolutionRuntime.slot(INTERACTOR_RESOLUTION_SLOT, (): InteractorResolutionState => ({ + runtimeSlot(INTERACTOR_RESOLUTION_SLOT, (): InteractorResolutionState => ({ resolution: NO_INTERACTOR_RESOLUTION, })); -/** The resolution one daemon runtime owns; the runtime installs its composition's into it. */ -export const createInteractorResolutionRuntimeState = (): RuntimeStateHandle => - interactorResolutionRuntime.createHandle(); - /** * Root composition's installation point, beside the provider-device admission that shares the * same request-scoped provider scope: one change names both. The interactor graph stays behind diff --git a/packages/daemon/src/core/provider-device-admission.ts b/packages/daemon/src/core/provider-device-admission.ts index 2a4bebef72..d4cd40c3ff 100644 --- a/packages/daemon/src/core/provider-device-admission.ts +++ b/packages/daemon/src/core/provider-device-admission.ts @@ -1,4 +1,4 @@ -import { defineRuntimeSlots, type RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; +import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; import type { DeviceInfo } from '@agent-device/kernel/device'; /** @@ -21,17 +21,12 @@ const NO_PROVIDER_DEVICE_ADMISSION: ProviderDeviceAdmission = { type ProviderAdmissionState = { admission: ProviderDeviceAdmission }; -const providerAdmissionRuntime = defineRuntimeSlots(); const PROVIDER_ADMISSION_SLOT = Symbol('providerDeviceAdmission'); const providerAdmissionState = (): ProviderAdmissionState => - providerAdmissionRuntime.slot(PROVIDER_ADMISSION_SLOT, (): ProviderAdmissionState => ({ + runtimeSlot(PROVIDER_ADMISSION_SLOT, (): ProviderAdmissionState => ({ admission: NO_PROVIDER_DEVICE_ADMISSION, })); -/** The admission one daemon runtime owns; the runtime installs its composition's into it. */ -export const createProviderDeviceAdmissionRuntimeState = (): RuntimeStateHandle => - providerAdmissionRuntime.createHandle(); - /** * Root composition's installation point. The provider runtime scope is ambient per request, so * this is the one place the daemon names where the fact comes from, and the daemon never reads diff --git a/packages/daemon/src/resources/artifact-tracking.test.ts b/packages/daemon/src/resources/artifact-tracking.test.ts index c2e4538847..4b0b95b4b4 100644 --- a/packages/daemon/src/resources/artifact-tracking.test.ts +++ b/packages/daemon/src/resources/artifact-tracking.test.ts @@ -2,11 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; import { mkdtempForTestSync } from '@agent-device/test-kit'; -import { - createArtifactTrackingRuntimeState, - trackDownloadableArtifact, - trackUploadedArtifact, -} from './artifact-tracking.ts'; +import { createRuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; +import { trackDownloadableArtifact, trackUploadedArtifact } from './artifact-tracking.ts'; test('disposing a runtime removes the temporary files it still tracks', () => { const root = mkdtempForTestSync('agent-device-artifact-dispose-'); @@ -15,7 +12,7 @@ test('disposing a runtime removes the temporary files it still tracks', () => { fs.writeFileSync(artifactPath, 'artifact'); fs.mkdirSync(uploadDir); fs.writeFileSync(path.join(uploadDir, 'payload'), 'payload'); - const runtime = createArtifactTrackingRuntimeState(); + const runtime = createRuntimeStateHandle(); runtime.run(() => { trackDownloadableArtifact({ diff --git a/packages/daemon/src/resources/artifact-tracking.ts b/packages/daemon/src/resources/artifact-tracking.ts index f3fa14bb69..a73a041a1e 100644 --- a/packages/daemon/src/resources/artifact-tracking.ts +++ b/packages/daemon/src/resources/artifact-tracking.ts @@ -1,4 +1,4 @@ -import { defineRuntimeSlots, type RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; +import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; @@ -50,10 +50,9 @@ type ArtifactTrackingState = { uploads: Map; }; -const artifactTrackingRuntime = defineRuntimeSlots(); const ARTIFACT_TRACKING_SLOT = Symbol('artifactTracking'); const artifactTracking = (): ArtifactTrackingState => - artifactTrackingRuntime.slot( + runtimeSlot( ARTIFACT_TRACKING_SLOT, (): ArtifactTrackingState => ({ artifacts: new Map(), uploads: new Map() }), (state) => { @@ -71,10 +70,6 @@ const artifactTracking = (): ArtifactTrackingState => const pendingArtifacts = () => artifactTracking().artifacts; const pendingUploads = () => artifactTracking().uploads; -/** The artifacts and uploads one daemon runtime tracks; disposing it stops their expiry timers. */ -export const createArtifactTrackingRuntimeState = (): RuntimeStateHandle => - artifactTrackingRuntime.createHandle(); - /** What a finished request registers for each artifact it hands back to the client. */ export type DownloadableArtifactRegistration = { artifactPath: string; diff --git a/packages/daemon/src/server/http/daemon-runtime-bounded-close.test.ts b/packages/daemon/src/server/http/daemon-runtime-bounded-close.test.ts index dfc61d3450..0a285b7e57 100644 --- a/packages/daemon/src/server/http/daemon-runtime-bounded-close.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-bounded-close.test.ts @@ -3,7 +3,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import { lifecycleDeviceRuntimeGateway } from '../../__tests__/support/test-device-runtime-gateway.ts'; import { expect, test, vi } from 'vitest'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import type { RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; +import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; @@ -178,22 +178,27 @@ test('unfinished platform owner shutdown is bounded, reports pending and retains }); const clearLock = vi.fn(async () => {}); const pluginShutdown = vi.fn(async () => []); + // The slot lives on the runtime's one entered store, so its disposer runs exactly when that + // store is disposed (#3794): it stands in for the per-runtime state the close must not drop + // while work is unfinished. const disposeState = vi.fn(); - const state: RuntimeStateHandle = { run: (fn) => fn(), enter: () => {}, dispose: disposeState }; + const heldSlot = Symbol('bounded-close-held-state'); const runtime = await startDaemonRuntime({ host: hostFor(stateDir), ...silent, shutdownBudgets: { ownerMs: 20 }, - composeDeps: daemonRuntimeDepsFixture({ - deviceRuntimeGateway: { - ...lifecycleDeviceRuntimeGateway, - applicationLifecycle: applicationLifecycleResourceFixture(), - shutdown: () => ownerClosed, - }, - ownerLifecycle: platformOwnerLifecycleFixture({ clearDaemonLockConfiguration: clearLock }), - shutdownProviderRuntimes: pluginShutdown, - runtimeStates: [state], - }), + composeDeps: async (input) => { + runtimeSlot(heldSlot, () => ({}), disposeState); + return await daemonRuntimeDepsFixture({ + deviceRuntimeGateway: { + ...lifecycleDeviceRuntimeGateway, + applicationLifecycle: applicationLifecycleResourceFixture(), + shutdown: () => ownerClosed, + }, + ownerLifecycle: platformOwnerLifecycleFixture({ clearDaemonLockConfiguration: clearLock }), + shutdownProviderRuntimes: pluginShutdown, + })(input); + }, }); const closing = runtime.close(); try { @@ -215,3 +220,25 @@ test('unfinished platform owner shutdown is bounded, reports pending and retains await closing; } }); + +test('a clean close disposes the state composition declared in the entered handle', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-bounded-close-clean-'); + const disposeState = vi.fn(); + const heldSlot = Symbol('bounded-close-clean-state'); + const runtime = await startDaemonRuntime({ + host: hostFor(stateDir), + ...silent, + composeDeps: async (input) => { + // The slot is declared through the store the runtime entered, so it only exists when + // composition runs inside the handle. The disposer running once at close proves both + // halves: composition entered the handle, and the close disposed that state. + runtimeSlot(heldSlot, () => ({}), disposeState); + return await daemonRuntimeDepsFixture({})(input); + }, + }); + + const report = await runtime.close(); + + expect(report.platformOwners).toBe('closed'); + expect(disposeState).toHaveBeenCalledOnce(); +}); diff --git a/packages/daemon/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index d60c4e9335..acb59b7ab5 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -42,9 +42,7 @@ import { finalizeDaemonSessionApplicationLifecycle } from '../application-lifecy import { runtimeHintValues } from '../../sessions/session-runtime.ts'; import { closeDaemonServers, - createRuntimeContext, teardownDaemonSessionForShutdown, - type RuntimeContext, type WritableOutput, createPluginClose, createStartupRollback, @@ -71,7 +69,10 @@ import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; import { withCommandHost } from '@agent-device/host-kit/command'; import { cancelAllRequests } from '@agent-device/host-kit/request'; -import type { RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; +import { + createRuntimeStateHandle, + type RuntimeStateHandle, +} from '@agent-device/host-kit/runtime-slots'; import { emitDiagnostic, type ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { @@ -134,8 +135,6 @@ const DAEMON_RECORDING_REAP_TERM_TIMEOUT_MS = 5_000; * same factory, so the production assembly is the one every runtime test boots. */ export type DaemonRuntimeDeps = Readonly<{ - /** State the composition's platform modules keep per daemon runtime (the Apple runner's). */ - runtimeStates: readonly RuntimeStateHandle[]; ownedProcessRecords: OwnedProcessRecordStore; hostDiagnostics: HostDiagnostics; ownerLifecycle: PlatformOwnerLifecycle; @@ -254,17 +253,23 @@ export function daemonStartupExitCode(error: unknown): number { return typeof exitCode === 'number' ? exitCode : 1; } +/** + * Starts one daemon runtime: the single entered runtime-state store, the command host, then the + * composition and servers built inside both. Every read of per-runtime state (requests, close, + * timers the startup spawns) runs within this handle, so two runtimes in one process never share + * it and no module exposes a store of its own (#3794). + */ export async function startDaemonRuntime( options: DaemonRuntimeOptions, ): Promise { - const runtimeContext = createRuntimeContext(); - return await runtimeContext.run( + const runtimeState = createRuntimeStateHandle(); + return await runtimeState.run( async () => await withCommandHost({ host: options.host, requestEnv: undefined }, async () => { const rollback = createStartupRollback(); - rollback.onRollback(() => runtimeContext.dispose()); + rollback.onRollback(() => runtimeState.dispose()); try { - return await startDaemonRuntimeOnHost(options, rollback, runtimeContext); + return await startDaemonRuntimeOnHost(options, rollback, runtimeState); } catch (error) { await rollback.run(); throw error; @@ -276,7 +281,7 @@ export async function startDaemonRuntime( async function startDaemonRuntimeOnHost( options: DaemonRuntimeOptions, rollback: StartupRollback, - runtimeContext: RuntimeContext, + runtimeState: RuntimeStateHandle, ): Promise { const { host } = options; const { env } = host; @@ -316,7 +321,6 @@ async function startDaemonRuntimeOnHost( }), openWebSessionNames: () => openWebSessionNames(sessionStore), }); - runtimeContext.extend(deps.runtimeStates); const appLogAdmissionLedger = createAppLogAdmissionLedger(); const audioProbeAdmissionLedger = createAudioProbeAdmissionLedger(); const perfCaptureAdmissionLedger = createPerfCaptureAdmissionLedger(); @@ -561,7 +565,7 @@ async function startDaemonRuntimeOnHost( const handleRequest: DaemonInvokeFn = lifecycleAdmission.admit(async (req) => { if (shuttingDown) return daemonShuttingDownResponse(); idleReap.cancel(); - const dispatch = runtimeContext.run(() => dispatchRequest(req)); + const dispatch = runtimeState.run(() => dispatchRequest(req)); inFlightRequests.add(dispatch); try { return await dispatch; @@ -911,7 +915,7 @@ async function startDaemonRuntimeOnHost( try { await deps.ownerLifecycle.clearDaemonLockConfiguration(); } catch {} - runtimeContext.dispose(); + runtimeState.dispose(); } } }; @@ -928,7 +932,7 @@ async function startDaemonRuntimeOnHost( closed.catch(() => {}); const close = (closeOptions: { cause?: unknown } = {}): Promise => { if (!closing) { - closing = runtimeContext.run(() => + closing = runtimeState.run(() => withCommandHost( { host, requestEnv: undefined }, async () => await closeOnHost(closeOptions), @@ -945,7 +949,7 @@ async function startDaemonRuntimeOnHost( httpPort, close, closed, - run: runtimeContext.run, + run: runtimeState.run, socketPort, token, }; diff --git a/packages/daemon/src/server/http/server-shutdown.ts b/packages/daemon/src/server/http/server-shutdown.ts index 62f352a99d..330d6f5615 100644 --- a/packages/daemon/src/server/http/server-shutdown.ts +++ b/packages/daemon/src/server/http/server-shutdown.ts @@ -5,17 +5,10 @@ import type { import { normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import type { DaemonServer } from './transport.ts'; import { sleep } from '@agent-device/host-kit/retry'; -import { createRequestCancelRuntimeState } from '@agent-device/host-kit/request'; -import type { RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; import type { RuntimeHintValues } from '@agent-device/contracts/application-lifecycle-runtime'; import { SessionStore } from '../../core/session-store.ts'; import type { SessionRef, SessionState } from '../../core/session-state.ts'; import type { PlatformResourceCleanup } from '../../core/platform-resource-cleanup.ts'; -import { createProviderDeviceAdmissionRuntimeState } from '../../core/provider-device-admission.ts'; -import { createInteractorResolutionRuntimeState } from '../../core/interactor-resolution.ts'; -import { createArtifactTrackingRuntimeState } from '../../resources/artifact-tracking.ts'; -import { createResumableUploadRuntimeState } from '../resumable-upload.ts'; -import { createMaterializedPathRuntimeState } from '../../sessions/materialized-path-registry.ts'; import { stopSessionAppLog, teardownSessionResources } from '../../sessions/session-teardown.ts'; import { resolveDaemonSessionTeardownTimeoutMs } from '../../sessions/session-teardown-budget.ts'; import { runtimeHintValues } from '../../sessions/session-runtime.ts'; @@ -372,39 +365,3 @@ export async function teardownDaemonSessionForShutdown(params: { sessionStore.retire(ref); return teardownSucceeded; } - -export type RuntimeContext = Readonly<{ - run(fn: () => T): T; - /** Adds state the composition contributed and binds it for the rest of the start. */ - extend(handles: readonly RuntimeStateHandle[]): void; - dispose(): void; -}>; - -/** The per-runtime state of every module that keeps some, in the order it is entered. */ -function createDaemonRuntimeStates(): RuntimeStateHandle[] { - return [ - createRequestCancelRuntimeState(), - createProviderDeviceAdmissionRuntimeState(), - createInteractorResolutionRuntimeState(), - createArtifactTrackingRuntimeState(), - createResumableUploadRuntimeState(), - createMaterializedPathRuntimeState(), - ]; -} - -/** The context one daemon runtime enters: every module's per-runtime state, composed in order. */ -export function createRuntimeContext(): RuntimeContext { - const states = createDaemonRuntimeStates(); - return { - run: (fn) => states.reduceRight((next, state) => () => state.run(next), fn)(), - extend: (handles) => { - for (const handle of handles) { - states.push(handle); - handle.enter(); - } - }, - dispose: () => { - for (const state of states) state.dispose(); - }, - }; -} diff --git a/packages/daemon/src/server/resumable-upload.ts b/packages/daemon/src/server/resumable-upload.ts index cb061c2be7..03109a4147 100644 --- a/packages/daemon/src/server/resumable-upload.ts +++ b/packages/daemon/src/server/resumable-upload.ts @@ -1,4 +1,4 @@ -import { defineRuntimeSlots, type RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; +import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -26,10 +26,9 @@ type ResumableUploadState = { byKey: Map; }; -const resumableUploadRuntime = defineRuntimeSlots(); const RESUMABLE_UPLOAD_SLOT = Symbol('resumableUploads'); const resumableUploads = (): ResumableUploadState => - resumableUploadRuntime.slot( + runtimeSlot( RESUMABLE_UPLOAD_SLOT, (): ResumableUploadState => ({ byId: new Map(), byKey: new Map() }), (state) => { @@ -46,10 +45,6 @@ const resumableUploads = (): ResumableUploadState => const RESUMABLE_UPLOADS_BY_ID = () => resumableUploads().byId; const RESUMABLE_UPLOADS_BY_KEY = () => resumableUploads().byKey; -/** The resumable uploads one daemon runtime holds; disposing it stops their expiry timers. */ -export const createResumableUploadRuntimeState = (): RuntimeStateHandle => - resumableUploadRuntime.createHandle(); - export type BeginResumableUploadOptions = { baseUrl: string; tokenHeaders: Record; diff --git a/packages/daemon/src/sessions/materialized-path-registry.ts b/packages/daemon/src/sessions/materialized-path-registry.ts index a5f6d1e459..f06a6714b1 100644 --- a/packages/daemon/src/sessions/materialized-path-registry.ts +++ b/packages/daemon/src/sessions/materialized-path-registry.ts @@ -1,4 +1,4 @@ -import { defineRuntimeSlots, type RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; +import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; import crypto from 'node:crypto'; import { rmSync } from 'node:fs'; import fs from 'node:fs/promises'; @@ -35,10 +35,9 @@ export type RetainedMaterializedPaths = { expiresAt: string; }; -const retainedPathsRuntime = defineRuntimeSlots(); const RETAINED_PATHS_SLOT = Symbol('materializedPaths'); const retainedPaths = (): Map => - retainedPathsRuntime.slot( + runtimeSlot( RETAINED_PATHS_SLOT, () => new Map(), (entries) => { @@ -53,10 +52,6 @@ const retainedPaths = (): Map => }, ); -/** The materialized paths one daemon runtime retains; disposing it stops their expiry timers. */ -export const createMaterializedPathRuntimeState = (): RuntimeStateHandle => - retainedPathsRuntime.createHandle(); - export async function retainMaterializedPaths(params: { installablePath: string; archivePath?: string; diff --git a/packages/host-kit/src/internal/request-cancel.ts b/packages/host-kit/src/internal/request-cancel.ts index 54bf01a9a0..0d9fb5dc0f 100644 --- a/packages/host-kit/src/internal/request-cancel.ts +++ b/packages/host-kit/src/internal/request-cancel.ts @@ -1,15 +1,14 @@ import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; -import { defineRuntimeSlots, type RuntimeStateHandle } from '../runtime-slots.ts'; +import { runtimeSlot } from '../runtime-slots.ts'; type RequestCancelState = { canceledRequestIds: Set; requestAbortControllers: Map; }; -const requestCancelRuntime = defineRuntimeSlots(); const REQUEST_CANCEL_SLOT = Symbol('requestCancel'); const requestCancelState = (): RequestCancelState => - requestCancelRuntime.slot( + runtimeSlot( REQUEST_CANCEL_SLOT, (): RequestCancelState => ({ canceledRequestIds: new Set(), @@ -22,9 +21,6 @@ const requestCancelState = (): RequestCancelState => const canceledRequestIds = () => requestCancelState().canceledRequestIds; const requestAbortControllers = () => requestCancelState().requestAbortControllers; -export const createRequestCancelRuntimeState = (): RuntimeStateHandle => - requestCancelRuntime.createHandle(); - export type RequestAbortRegistration = { requestId: string; controller: AbortController; diff --git a/packages/host-kit/src/request.ts b/packages/host-kit/src/request.ts index 8d22ba01b6..241f236297 100644 --- a/packages/host-kit/src/request.ts +++ b/packages/host-kit/src/request.ts @@ -1,7 +1,6 @@ export { cancelAllRequests, clearRequestAbortRegistration, - createRequestCancelRuntimeState, clearRequestCanceled, getRequestSignal, isRequestCanceled, diff --git a/packages/host-kit/src/runtime-slots.ts b/packages/host-kit/src/runtime-slots.ts index ec91863f07..229aa467a2 100644 --- a/packages/host-kit/src/runtime-slots.ts +++ b/packages/host-kit/src/runtime-slots.ts @@ -1,48 +1,40 @@ import { AsyncLocalStorage } from 'node:async_hooks'; /** - * What one daemon runtime owns of a kind: a module (or a package) keeps its state in slots of an - * async-local store the runtime enters, so two runtimes in one process never share it. Outside a - * runtime (a client process, a unit test) the store's own standalone state answers. + * What one daemon runtime owns: a handle over the process's one slot store. The runtime enters + * it once (through {@link RuntimeStateHandle.run}) and disposes it once at close, so two runtimes + * in one process never share per-runtime state. Outside a runtime (a client process, a unit + * test) the store's own standalone slots answer. */ export type RuntimeStateHandle = Readonly<{ /** Runs `fn` as the runtime that owns this state. */ run(fn: () => T): T; - /** Binds this state for the rest of the current async context, after the runtime is composed. */ - enter(): void; /** Releases what the state holds: each slot's disposer runs, then the slots are forgotten. */ dispose(): void; }>; -export type RuntimeSlots = Readonly<{ - /** One piece of state the current runtime owns, created on first use. */ - slot(key: symbol, create: () => T, dispose?: (value: T) => void): T; - /** The state of one new runtime: enter it before anything reads a slot. */ - createHandle(): RuntimeStateHandle; -}>; - type Slot = { value: unknown; dispose: ((value: never) => void) | undefined }; -/** The single owner of the "entered store, else standalone store" pattern; callers declare slots. */ -export function defineRuntimeSlots(): RuntimeSlots { - const storage = new AsyncLocalStorage>(); - const standalone = new Map(); +// One store per process: modules declare only slot keys, and each runtime enters exactly one +// handle of this store. +const storage = new AsyncLocalStorage>(); +const standalone = new Map(); + +/** One piece of state the current runtime owns, created on first use. */ +export function runtimeSlot(key: symbol, create: () => T, dispose?: (value: T) => void): T { + const slots = storage.getStore() ?? standalone; + if (!slots.has(key)) slots.set(key, { value: create(), dispose }); + return slots.get(key)!.value as never; +} + +/** The state of one new runtime: enter it before anything reads a slot. */ +export function createRuntimeStateHandle(): RuntimeStateHandle { + const owned = new Map(); return { - slot: (key, create, dispose) => { - const slots = storage.getStore() ?? standalone; - if (!slots.has(key)) slots.set(key, { value: create(), dispose }); - return slots.get(key)!.value as never; - }, - createHandle: () => { - const owned = new Map(); - return { - run: (fn) => storage.run(owned, fn), - enter: () => storage.enterWith(owned), - dispose: () => { - for (const { value, dispose } of owned.values()) dispose?.(value as never); - owned.clear(); - }, - }; + run: (fn) => storage.run(owned, fn), + dispose: () => { + for (const { value, dispose } of owned.values()) dispose?.(value as never); + owned.clear(); }, }; } diff --git a/packages/platform-android/src/__tests__/snapshot-helper-session-runtime-isolation.test.ts b/packages/platform-android/src/__tests__/snapshot-helper-session-runtime-isolation.test.ts index 874720caa3..e59fb1d625 100644 --- a/packages/platform-android/src/__tests__/snapshot-helper-session-runtime-isolation.test.ts +++ b/packages/platform-android/src/__tests__/snapshot-helper-session-runtime-isolation.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { createAndroidHelperRuntimeState } from '../adb-host.ts'; +import { createRuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; import { recoverAndroidSnapshotHelperRetirement } from '../snapshot-helper-retirement.ts'; import { resetAndroidSnapshotHelperSessions, @@ -19,7 +19,7 @@ function runtimeOnDevice(deviceKey: string, options: Partial => - helperRuntimeSlot(deviceExitStatusForwardingSlot, () => new Map()); + runtimeSlot(deviceExitStatusForwardingSlot, () => new Map()); /** * Whether the exit status of a host `adb shell` child is the DEVICE command's exit status. diff --git a/packages/platform-android/src/snapshot-helper-retirement.ts b/packages/platform-android/src/snapshot-helper-retirement.ts index 7c9200a220..8e58094b8d 100644 --- a/packages/platform-android/src/snapshot-helper-retirement.ts +++ b/packages/platform-android/src/snapshot-helper-retirement.ts @@ -1,4 +1,4 @@ -import { helperRuntimeSlot } from './adb-host.ts'; +import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { shellFragment } from '@agent-device/kernel/device-shell'; @@ -40,7 +40,7 @@ type PendingRetirement = { const pendingRetirementsSlot = Symbol('androidPendingRetirements'); const pendingRetirements = (): Map => - helperRuntimeSlot(pendingRetirementsSlot, () => new Map()); + runtimeSlot(pendingRetirementsSlot, () => new Map()); export function getAndroidSnapshotHelperSessionDeviceKey( device: Pick, diff --git a/packages/platform-android/src/snapshot-helper-session-lifecycle.ts b/packages/platform-android/src/snapshot-helper-session-lifecycle.ts index 7a5c501000..fddfa69945 100644 --- a/packages/platform-android/src/snapshot-helper-session-lifecycle.ts +++ b/packages/platform-android/src/snapshot-helper-session-lifecycle.ts @@ -7,7 +7,8 @@ * `snapshot-helper-session.ts`; they acquire through here and never reach the registry themselves. */ import type { AndroidAdbProcess } from './adb-executor.ts'; -import { helperRuntimeSlot, requireAndroidAdbHost } from './adb-host.ts'; +import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; +import { requireAndroidAdbHost } from './adb-host.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; @@ -96,14 +97,14 @@ export type AndroidSnapshotHelperSessionAcquisition = { const sessionsSlot = Symbol('androidSnapshotHelperSessions'); const sessions = (): Map => - helperRuntimeSlot(sessionsSlot, () => new Map()); + runtimeSlot(sessionsSlot, () => new Map()); /** * Capture identity → when this process may spawn that helper build again after a failed start or a * failed session capture. */ const failedSessionsSlot = Symbol('androidSnapshotHelperFailedSessions'); const failedSessions = (): Map => - helperRuntimeSlot(failedSessionsSlot, () => new Map()); + runtimeSlot(failedSessionsSlot, () => new Map()); /** * Starts (or reuses) the session without capturing, so a helper-backed read that is not a snapshot diff --git a/packages/platform-apple/src/core/runner-host.ts b/packages/platform-apple/src/core/runner-host.ts index 9e6bee1435..4f94713b87 100644 --- a/packages/platform-apple/src/core/runner-host.ts +++ b/packages/platform-apple/src/core/runner-host.ts @@ -23,6 +23,7 @@ import { readHostCpuArchSync, } from '@agent-device/host-kit/process'; import { Deadline, isEnvTruthy, retryWithPolicy } from '@agent-device/host-kit/retry'; +import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; import { withKeyedLock } from '@agent-device/kernel/keyed-lock'; @@ -45,7 +46,6 @@ import { getAppleRunnerHostEnvironment, getRunnerDeviceClaimAuthorityProbe, getRunnerLeaseOwnerStateDir, - runnerRuntimeSlot, } from './runner-owner-state.ts'; import { buildSimctlArgsForDevice, simulatorAddressFor } from './simctl.ts'; import { @@ -121,6 +121,6 @@ export const appleRunnerHost: AppleRunnerHost = { resolveIosPhysicalDeviceControl, visitXmlPlistEntries, leaseOwnerStateDir: getRunnerLeaseOwnerStateDir, - runnerRuntimeSlot, + runnerRuntimeSlot: runtimeSlot, hasDeviceClaimAuthority: (device) => getRunnerDeviceClaimAuthorityProbe()?.(device) ?? false, }; diff --git a/packages/platform-apple/src/core/runner-owner-state.test.ts b/packages/platform-apple/src/core/runner-owner-state.test.ts index 9923251c5d..b9c96e08b0 100644 --- a/packages/platform-apple/src/core/runner-owner-state.test.ts +++ b/packages/platform-apple/src/core/runner-owner-state.test.ts @@ -1,23 +1,27 @@ import { expect, test } from 'vitest'; import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; +import { createRuntimeStateHandle, runtimeSlot } from '@agent-device/host-kit/runtime-slots'; import { appleRunnerHost } from './runner-host.ts'; import { - createAppleRunnerRuntimeState, getRunnerLeaseOwnerStateDir, - runnerRuntimeSlot, + setAppleRunnerHostEnvironment, setRunnerLeaseOwnerStateDir, } from './runner-owner-state.ts'; const SLOT = Symbol('test-slot'); test('each runtime keeps its own owner state and registries', () => { - const first = createAppleRunnerRuntimeState(createLiveTestHostEnvironment()); - const second = createAppleRunnerRuntimeState(createLiveTestHostEnvironment()); + const first = createRuntimeStateHandle(); + const second = createRuntimeStateHandle(); + const firstHost = createLiveTestHostEnvironment(); + const secondHost = createLiveTestHostEnvironment(); + first.run(() => setAppleRunnerHostEnvironment(firstHost)); + second.run(() => setAppleRunnerHostEnvironment(secondHost)); first.run(() => { setRunnerLeaseOwnerStateDir(' /state/first '); - runnerRuntimeSlot(SLOT, () => new Map()).set('device', 'first'); + runtimeSlot(SLOT, () => new Map()).set('device', 'first'); }); second.run(() => { setRunnerLeaseOwnerStateDir('/state/second'); @@ -25,21 +29,24 @@ test('each runtime keeps its own owner state and registries', () => { expect(first.run(() => getRunnerLeaseOwnerStateDir())).toBe('/state/first'); expect(second.run(() => getRunnerLeaseOwnerStateDir())).toBe('/state/second'); - expect( - first.run(() => runnerRuntimeSlot(SLOT, () => new Map()).get('device')), - ).toBe('first'); - expect(second.run(() => runnerRuntimeSlot(SLOT, () => new Map()).size)).toBe(0); + // Each runtime's runner host port answers from the host bound in that runtime, never the other's. + expect(first.run(() => appleRunnerHost.hostEnvironment())).toBe(firstHost); + expect(second.run(() => appleRunnerHost.hostEnvironment())).toBe(secondHost); + expect(first.run(() => runtimeSlot(SLOT, () => new Map()).get('device'))).toBe( + 'first', + ); + expect(second.run(() => runtimeSlot(SLOT, () => new Map()).size)).toBe(0); // Nothing leaked into the standalone state the code outside any runtime reads. expect(getRunnerLeaseOwnerStateDir()).toBeUndefined(); }); test('disposing a runtime drops its registries', () => { - const runtime = createAppleRunnerRuntimeState(createLiveTestHostEnvironment()); - runtime.run(() => runnerRuntimeSlot(SLOT, () => new Map([['device', 'owned']]))); + const runtime = createRuntimeStateHandle(); + runtime.run(() => runtimeSlot(SLOT, () => new Map([['device', 'owned']]))); runtime.dispose(); - expect(runtime.run(() => runnerRuntimeSlot(SLOT, () => new Map()).size)).toBe(0); + expect(runtime.run(() => runtimeSlot(SLOT, () => new Map()).size)).toBe(0); }); // A configuration read outside every composed runtime has no host to answer from; it refuses diff --git a/packages/platform-apple/src/core/runner-owner-state.ts b/packages/platform-apple/src/core/runner-owner-state.ts index 0468c85c4c..dbb805711b 100644 --- a/packages/platform-apple/src/core/runner-owner-state.ts +++ b/packages/platform-apple/src/core/runner-owner-state.ts @@ -1,6 +1,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; -import { defineRuntimeSlots, type RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; +import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; /** * Daemon-owned device-claim arbitration probe: does the embedding process hold @@ -12,48 +12,22 @@ import { defineRuntimeSlots, type RuntimeStateHandle } from '@agent-device/host- */ export type RunnerDeviceClaimAuthorityProbe = (device: DeviceInfo) => boolean; -/** - * What one daemon runtime owns of the Apple runner: the lease-owner state directory and claim probe - * its daemon set, and the registries (sessions, locks, recycle ledger) the runner modules keep in - * {@link runnerRuntimeSlot}s. Kept in a dedicated tiny module so daemon startup can reach it - * without loading the runner client's implementation closure; the runner package reads it through - * its host port. - */ -const runnerRuntime = defineRuntimeSlots(); - -/** - * The state of one runtime's Apple runner, with the host that runtime captured bound into it. - * Root composition passes the same host its deps carry, so runner configuration reads and every - * other configuration read of that runtime agree on one object (#3794). - */ -export function createAppleRunnerRuntimeState(host: HostEnvironment): RuntimeStateHandle { - const state = runnerRuntime.createHandle(); - const bind = (fn: () => T): T => - state.run(() => { - runnerOwner().hostEnvironment = host; - return fn(); - }); - return Object.freeze({ - run: bind, - enter: () => { - state.enter(); - runnerOwner().hostEnvironment = host; - }, - dispose: () => state.dispose(), - }); -} - -/** One piece of runner state this daemon runtime owns, created on first use. */ -export const runnerRuntimeSlot = runnerRuntime.slot; - type RunnerOwner = { leaseOwnerStateDir: string | undefined; deviceClaimAuthorityProbe: RunnerDeviceClaimAuthorityProbe | undefined; hostEnvironment: HostEnvironment | undefined; }; + +/** + * The Apple runner's per-runtime owner state: the lease-owner state directory and claim probe + * its daemon set, and the host that daemon's composition bound (#3794). The runner's registries + * (sessions, locks, recycle ledger) are separate slots the runner modules declare on the same + * store through the runner host port. Kept in a dedicated tiny module so daemon startup can + * reach it without loading the runner client's implementation closure. + */ const RUNNER_OWNER_SLOT = Symbol('appleRunnerOwner'); const runnerOwner = (): RunnerOwner => - runnerRuntimeSlot(RUNNER_OWNER_SLOT, (): RunnerOwner => ({ + runtimeSlot(RUNNER_OWNER_SLOT, (): RunnerOwner => ({ leaseOwnerStateDir: undefined, deviceClaimAuthorityProbe: undefined, hostEnvironment: undefined, diff --git a/packages/platform-apple/src/runner-owner-facade.ts b/packages/platform-apple/src/runner-owner-facade.ts index 53f6e2892d..c0d644cfd0 100644 --- a/packages/platform-apple/src/runner-owner-facade.ts +++ b/packages/platform-apple/src/runner-owner-facade.ts @@ -1,5 +1,4 @@ export { - createAppleRunnerRuntimeState, setAppleRunnerHostEnvironment, setRunnerDeviceClaimAuthorityProbe, setRunnerLeaseOwnerStateDir, diff --git a/packages/platform-apple/src/runner/__tests__/runner-lease-runtime-host.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lease-runtime-host.test.ts index a5db211675..76c3698813 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lease-runtime-host.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lease-runtime-host.test.ts @@ -2,7 +2,8 @@ import { expect, test, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from './tmp-dir.ts'; -import { createAppleRunnerRuntimeState } from '../../core/runner-owner-state.ts'; +import { createTestRuntimeStateHandle } from '@agent-device/test-kit'; +import { setAppleRunnerHostEnvironment } from '../../core/runner-owner-state.ts'; import { currentRunnerLeaseOwnerToken, writeRunnerLease } from '../runner-lease.ts'; // Each composed runtime reads its runner configuration from the host it was composed with, not @@ -14,14 +15,20 @@ test("each runner runtime resolves its host's runner lease directory, not the pr const root = mkdtempForTestSync('apple-runner-lease-host-'); const dirA = path.join(root, 'a'); const dirB = path.join(root, 'b'); - const runtimeA = createAppleRunnerRuntimeState({ - env: { ...process.env, AGENT_DEVICE_IOS_RUNNER_LEASE_DIR: dirA }, - cwd: process.cwd(), - }); - const runtimeB = createAppleRunnerRuntimeState({ - env: { ...process.env, AGENT_DEVICE_IOS_RUNNER_LEASE_DIR: dirB }, - cwd: process.cwd(), - }); + const runtimeA = createTestRuntimeStateHandle(); + const runtimeB = createTestRuntimeStateHandle(); + runtimeA.run(() => + setAppleRunnerHostEnvironment({ + env: { ...process.env, AGENT_DEVICE_IOS_RUNNER_LEASE_DIR: dirA }, + cwd: process.cwd(), + }), + ); + runtimeB.run(() => + setAppleRunnerHostEnvironment({ + env: { ...process.env, AGENT_DEVICE_IOS_RUNNER_LEASE_DIR: dirB }, + cwd: process.cwd(), + }), + ); runtimeA.run(() => writeRunnerLease({ diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-runtime-isolation.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-runtime-isolation.test.ts index 09f480fa5d..69c51cf260 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-runtime-isolation.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-runtime-isolation.test.ts @@ -2,12 +2,15 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { beforeEach, test, vi } from 'vitest'; import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; -import { createAppleRunnerRuntimeState } from '../../core/runner-owner-state.ts'; import { appleRunnerTestHost } from '../test-host.ts'; import { registerRunnerPrepProcess } from '../runner-artifact.ts'; import { stopAllIosRunnerSessions } from '../runner-session.ts'; import { openRunnerStartAdmission, runnerPrepProcessChildren } from '../runner-xctestrun.ts'; -import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; +import { setAppleRunnerHostEnvironment } from '../../core/runner-owner-state.ts'; +import { + createLiveTestHostEnvironment, + createTestRuntimeStateHandle, +} from '@agent-device/test-kit'; const mockSignalProcessGroupBestEffort = vi.fn(); const mockRunAppleToolCommand = vi.fn(); @@ -30,8 +33,10 @@ function prepChild(pid: number): ExecBackgroundResult['child'] { } test("stopping every runner of one runtime leaves another runtime's prep build and admission alone", async () => { - const runtimeA = createAppleRunnerRuntimeState(createLiveTestHostEnvironment()); - const runtimeB = createAppleRunnerRuntimeState(createLiveTestHostEnvironment()); + const runtimeA = createTestRuntimeStateHandle(); + const runtimeB = createTestRuntimeStateHandle(); + runtimeA.run(() => setAppleRunnerHostEnvironment(createLiveTestHostEnvironment())); + runtimeB.run(() => setAppleRunnerHostEnvironment(createLiveTestHostEnvironment())); const deviceId = 'runner-runtime-isolation-sim'; const buildA = prepChild(6101); const buildB = prepChild(6102); diff --git a/packages/platform-apple/src/runner/host.ts b/packages/platform-apple/src/runner/host.ts index c1ae78b3fb..d6ffdd770e 100644 --- a/packages/platform-apple/src/runner/host.ts +++ b/packages/platform-apple/src/runner/host.ts @@ -4,6 +4,7 @@ import type * as HostFile from '@agent-device/host-kit/file'; import type * as HostProcess from '@agent-device/host-kit/process'; import type * as HostRequest from '@agent-device/host-kit/request'; import type * as HostRetry from '@agent-device/host-kit/retry'; +import type * as HostRuntimeSlots from '@agent-device/host-kit/runtime-slots'; import type * as HostVersion from '@agent-device/host-kit/version'; import type * as KernelDeviceShell from '@agent-device/kernel/device-shell'; import type * as KernelKeyedLock from '@agent-device/kernel/keyed-lock'; @@ -102,8 +103,11 @@ export type AppleRunnerHost = Pick< resolveIosPhysicalDeviceControl: (device: DeviceInfo) => IosPhysicalDeviceRunnerControl; /** Daemon-owned lease owner state directory. */ leaseOwnerStateDir: typeof AppleRunnerOwnerState.getRunnerLeaseOwnerStateDir; - /** One registry the daemon runtime owns (sessions, locks, ledgers), created on first use. */ - runnerRuntimeSlot: typeof AppleRunnerOwnerState.runnerRuntimeSlot; + /** + * One registry the current runtime owns (sessions, locks, ledgers), created on first use. + * Fronts host-kit's one runtime slot store, so the runner declares keys and never a store. + */ + runnerRuntimeSlot: typeof HostRuntimeSlots.runtimeSlot; /** * Daemon-owned device-claim arbitration probe: true only while the embedding process holds * the host-global local device claim for exactly this device (matched by canonical diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 787770ff54..144e709ce5 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -120,6 +120,7 @@ export { unavailableApplicationLifecycleOperationFacts, unavailableDeploymentSnapshotAndShutdownOperationFacts, } from './runtime-operation-facts.ts'; +export { createTestRuntimeStateHandle, type TestRuntimeStateHandle } from './runtime-state.ts'; export { buildNodes } from './snapshot-builders.ts'; export { bottomTabsRealCaptureFixture, diff --git a/packages/test-kit/src/runtime-state.ts b/packages/test-kit/src/runtime-state.ts new file mode 100644 index 0000000000..83685cd7ac --- /dev/null +++ b/packages/test-kit/src/runtime-state.ts @@ -0,0 +1,15 @@ +import { createRuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; + +export type { RuntimeStateHandle as TestRuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; + +/** + * The state of one test runtime: a handle over host-kit's one slot store, entered with `run` and + * released with `dispose`. A test that exercises per-runtime state enters its own handles, so two + * "runtimes" in one test never share it, and nothing leaks into the standalone state that code + * outside any runtime reads (#3794). Production code never calls this: the daemon runtime owns + * the one handle it creates at start, and a test that boots a real daemon needs no handle of its + * own. + */ +export function createTestRuntimeStateHandle(): ReturnType { + return createRuntimeStateHandle(); +} diff --git a/src/daemon-composition.test.ts b/src/daemon-composition.test.ts index 4890bb61d6..eaa80adac2 100644 --- a/src/daemon-composition.test.ts +++ b/src/daemon-composition.test.ts @@ -10,7 +10,8 @@ import { createTestHostEnvironment, } from '@agent-device/test-kit'; import { createDaemonRuntimeDeps } from './daemon-composition.ts'; -import { helperRuntimeSlot } from '@agent-device/platform-android/adb-host'; +import { createRuntimeStateHandle, runtimeSlot } from '@agent-device/host-kit/runtime-slots'; +import { resolveRunnerAppBundleId } from '@agent-device/platform-apple/runner/operations'; import { setActiveProviderDeviceRuntimes } from './provider-device-runtime.ts'; const ports = vi.hoisted(() => ({ @@ -126,13 +127,20 @@ test('a composition that succeeds leaves the plugins open for the daemon to clos expect(ports.shutdownProviders).toHaveBeenCalledOnce(); }); -test('a composed daemon registers the Apple runner and Android helper state, one set per composition', async () => { +test('each composition binds its own runtime state into the entered handle, not a returned one', async () => { const compose = async (name: string) => { const baseDir = mkdtempForTestSync(`agent-device-daemon-composition-states-${name}-`); - return await createDaemonRuntimeDeps({ - host: createTestHostEnvironment({ - env: { ...process.env, AGENT_DEVICE_STATE_DIR: baseDir }, - }), + const host = createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: baseDir, + // The runner's first-choice configuration key: the per-runtime read below must see this + // runtime's value even when the process env (spread above) carries a different one. + AGENT_DEVICE_IOS_BUNDLE_ID: `com.test.runtime.${name}`, + }, + }); + const deps = await createDaemonRuntimeDeps({ + host, baseDir, sessionsDir: `${baseDir}/sessions`, assertShutdownAllowed: undefined, @@ -142,21 +150,28 @@ test('a composed daemon registers the Apple runner and Android helper state, one }, openWebSessionNames: () => [], }); + return { deps, host }; }; - const first = await compose('first'); - const second = await compose('second'); const slotKey = Symbol('composition-test'); - // Apple's runner state and Android's helper state. - expect(first.runtimeStates).toHaveLength(2); - expect(second.runtimeStates).toHaveLength(2); - // The Android handle scopes the slots the helper modules read. - const [, firstAndroid] = first.runtimeStates; - const [, secondAndroid] = second.runtimeStates; - const inFirst = firstAndroid!.run(() => helperRuntimeSlot(slotKey, () => ({}))); - const inSecond = secondAndroid!.run(() => helperRuntimeSlot(slotKey, () => ({}))); + // One entered store per runtime (#3794): the daemon enters the handle, then composes into it, + // so each composition's writes — the Apple runner host, and any slot a platform module declares + // — land in that runtime's state and nowhere else. The host binding is read back through the + // runner's own configuration read (`resolveRunnerAppBundleId` takes its env from the bound + // host), the same path production resource cleanup uses. + const first = createRuntimeStateHandle(); + const second = createRuntimeStateHandle(); + const firstRun = await first.run(() => compose('first')); + const secondRun = await second.run(() => compose('second')); + expect(firstRun.deps).toBeTypeOf('object'); + expect(secondRun.deps).toBeTypeOf('object'); + + const inFirst = first.run(() => runtimeSlot(slotKey, () => ({}))); + const inSecond = second.run(() => runtimeSlot(slotKey, () => ({}))); expect(inFirst).not.toBe(inSecond); - expect(firstAndroid!.run(() => helperRuntimeSlot(slotKey, () => ({})))).toBe(inFirst); + expect(first.run(() => runtimeSlot(slotKey, () => ({})))).toBe(inFirst); + expect(first.run(() => resolveRunnerAppBundleId())).toBe('com.test.runtime.first'); + expect(second.run(() => resolveRunnerAppBundleId())).toBe('com.test.runtime.second'); }); test('the composed daemon route configures interactors from its host, not from the process (#3794)', async () => { diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts index c2367e27bf..7fb7f90d27 100644 --- a/src/daemon-composition.ts +++ b/src/daemon-composition.ts @@ -133,15 +133,13 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( const { createDaemonPlatformServices } = await import('./platform-runtime-daemon-services.ts'); const { createClaimRecoveryGatewayFor } = await import('./platform-runtime-claim-recovery.ts'); - const { createAppleRunnerRuntimeState } = + // The Apple runner reads its configuration from this runtime's host. Composition runs inside + // the runtime's entered state, so binding the host here writes that state's slot directly; + // the Android helper needs no binding — its modules declare slot keys on the same store. + const { setAppleRunnerHostEnvironment } = await import('@agent-device/platform-apple/runner-owner'); - const { createAndroidHelperRuntimeState } = - await import('@agent-device/platform-android/adb-host'); + setAppleRunnerHostEnvironment(host); return Object.freeze({ - runtimeStates: Object.freeze([ - createAppleRunnerRuntimeState(host), - createAndroidHelperRuntimeState(), - ]), ownedProcessRecords, hostDiagnostics, ownerLifecycle: createPlatformDaemonLifecycleOwners(host),