From d0c47f83214548b2ec7b3a7349b6af43e07e590a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 00:09:40 +0200 Subject: [PATCH 1/2] refactor(daemon): compose claim-recovery gateways at the process root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stale-claim reconciliation rebuilds the dead owner’s runtime gateway — the act that names platform modules. The daemon keeps the transaction (the dead owner’s recorded state dir, artifact paths, owned-process store, disposal) and asks root composition for the gateway through a required ClaimRecoveryGatewayFactory (src/daemon/device/claim-recovery-gateway.ts), threaded root -> RequestRouterDeps -> request scope. Deletes the default root-gateway construction in device-claim-owner-recovery.ts: recovery never touches the current daemon’s gateway. --- .../0019-request-bound-platform-runtime.md | 6 +- .../daemon-platform-runtime-inventory.test.ts | 77 +++++------ .../daemon-platform-runtime-inventory.ts | 74 +++++++++-- scripts/layering/root-module-zones.ts | 1 - src/cli/commands/device-release.ts | 8 +- .../__tests__/human-control-router-fixture.ts | 6 +- .../__tests__/platform-services-fixture.ts | 11 ++ ...quest-execution-scope-lease-expiry.test.ts | 7 +- ...st-execution-scope-macos-app-lease.test.ts | 7 +- ...t-execution-scope-open-device-wait.test.ts | 10 +- .../__tests__/request-execution-scope.test.ts | 11 +- .../request-recording-health.test.ts | 8 +- .../__tests__/request-router-open.test.ts | 6 +- .../__tests__/session-idle-activity.test.ts | 10 +- .../__tests__/test-device-runtime-gateway.ts | 10 +- .../__tests__/device-claim-admission.test.ts | 6 +- .../device-claim-owner-recovery.test.ts | 125 +++++++++++++++--- src/daemon/device/claim-recovery-gateway.ts | 25 ++++ .../device/device-claim-owner-recovery.ts | 32 +++-- .../handlers/__tests__/human-control.test.ts | 10 +- ...ession-boot-shutdown-device-claims.test.ts | 6 +- src/daemon/request-execution-scope.ts | 9 +- src/daemon/request-router.ts | 15 ++- src/daemon/server/daemon-runtime.ts | 14 +- src/platform-runtime-claim-recovery.ts | 17 +++ .../real-scope-serialization.ts | 6 +- .../integration/provider-scenarios/harness.ts | 2 + 27 files changed, 415 insertions(+), 104 deletions(-) create mode 100644 src/daemon/device/claim-recovery-gateway.ts create mode 100644 src/platform-runtime-claim-recovery.ts diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 19a2890e39..62bd6c9063 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -827,6 +827,7 @@ matching the layering scanner's scope) to `src/platforms/**` and concrete `@agent-device/platform-*` packages. R13 governs concrete package imports across the whole tree, while `retired-platforms-zone` prevents the old root seam from being recreated; platform freedom is therefore structurally enforced rather than periodically measured. + ### 11. The platform-services port carries host asks that bind no device Some request-path work asks the host machine something without ever taking a device runtime: @@ -834,7 +835,10 @@ concrete local readiness, boot-time and runner-session observation, and open-tar classification. Those asks now cross the daemon boundary as one required dependency, `DaemonPlatformServices` (`src/daemon/platform-services.ts`), composed by the root in `src/platform-runtime-daemon-services.ts` and threaded root → `RequestRouterDeps` → the request -execution scope → the handlers and consumers. +execution scope → the handlers and consumers. Stale-claim reconciliation gets the same treatment +for the one act it may not perform itself: the root supplies a `ClaimRecoveryGatewayFactory` +(`src/daemon/device/claim-recovery-gateway.ts`), and the daemon keeps the transaction — what to +rebuild from (the dead owner's recorded state dir), in what order, and when to dispose it. The port is not a raw escape hatch back into platform mechanics. Its observation members are the already-published neutral contract types from `@agent-device/contracts`, readiness is a boolean, diff --git a/scripts/layering/daemon-platform-runtime-inventory.test.ts b/scripts/layering/daemon-platform-runtime-inventory.test.ts index 3d90eb54f8..2bd73cd8f0 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.test.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.test.ts @@ -8,11 +8,9 @@ import { isRootPlatformRuntimeTarget, } from './daemon-platform-runtime-inventory.ts'; -// The classified pair these fixtures plant is the platform-services composition factory: the -// edge this port introduced, and the shortest one whose symbols the rule still classifies. -const CLASSIFIED_TARGET = 'src/platform-runtime-daemon-services.ts'; -const CLASSIFIED_STUB = 'export function createDaemonPlatformServices() { return {}; }\n'; -const CLASSIFIED_IMPORTER = 'src/daemon/server/daemon-runtime.ts'; +const DEVICE_READY_TARGET = 'src/platform-runtime-device-ready.ts'; +const DEVICE_READY_STUB = + 'export async function ensureLocalPlatformDeviceReady(device: unknown) { return false; }\n'; function violations(sources: Record): LayeringViolation[] { return checkDaemonPlatformRuntimeInventory(resolveImportEdges(new Map(Object.entries(sources)))); @@ -24,12 +22,12 @@ function edgeViolations(sources: Record, file: string): Layering test('R76 accepts a classified edge with the exact recorded symbols', () => { const sources = { - [CLASSIFIED_TARGET]: CLASSIFIED_STUB, - [CLASSIFIED_IMPORTER]: - "import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" + - 'void createDaemonPlatformServices;\n', + [DEVICE_READY_TARGET]: DEVICE_READY_STUB, + 'src/daemon/device/device-ready.ts': + "import { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + + 'void ensureLocalPlatformDeviceReady;\n', }; - assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []); + assert.deepEqual(edgeViolations(sources, 'src/daemon/device/device-ready.ts'), []); }); for (const [file, target, symbol] of [ @@ -52,18 +50,20 @@ for (const [file, target, symbol] of [ test('R76 reports every classified edge missing from the tree as stale, not the other way around', () => { const sources = { - [CLASSIFIED_TARGET]: CLASSIFIED_STUB, - [CLASSIFIED_IMPORTER]: - "import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" + - 'void createDaemonPlatformServices;\n', + [DEVICE_READY_TARGET]: DEVICE_READY_STUB, + 'src/daemon/device/device-ready.ts': + "import { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + + 'void ensureLocalPlatformDeviceReady;\n', }; const stale = violations(sources).filter( (violation) => violation.file === 'scripts/layering/daemon-platform-runtime-inventory.ts', ); assert.equal(stale.length, DAEMON_PLATFORM_RUNTIME_EDGES.length - 1); assert.ok(stale.every((violation) => violation.message.includes('stale classified edge'))); - const classifiedStale = stale.find((violation) => violation.message.includes(CLASSIFIED_TARGET)); - assert.equal(classifiedStale, undefined); + const deviceReadyStale = stale.find((violation) => + violation.message.includes(DEVICE_READY_TARGET), + ); + assert.equal(deviceReadyStale, undefined); }); test('R76 rejects an unclassified edge with the pair and its line', () => { @@ -86,32 +86,35 @@ test('R76 rejects an unclassified edge with the pair and its line', () => { test('R76 rejects new symbols on a classified edge', () => { const sources = { - [CLASSIFIED_TARGET]: CLASSIFIED_STUB + 'export function extraServicesKnob() {}\n', - [CLASSIFIED_IMPORTER]: - "import { createDaemonPlatformServices, extraServicesKnob } from '../../platform-runtime-daemon-services.ts';\n" + - 'void [createDaemonPlatformServices, extraServicesKnob];\n', + [DEVICE_READY_TARGET]: DEVICE_READY_STUB + 'export function extraReadiness() {}\n', + 'src/daemon/device/device-ready.ts': + "import { ensureLocalPlatformDeviceReady, extraReadiness } from '../../platform-runtime-device-ready.ts';\n" + + 'void [ensureLocalPlatformDeviceReady, extraReadiness];\n', }; - const found = edgeViolations(sources, CLASSIFIED_IMPORTER); + const found = edgeViolations(sources, 'src/daemon/device/device-ready.ts'); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /classified symbols drifted/); - assert.match(found[0]!.message, /createDaemonPlatformServices, extraServicesKnob/); + assert.match(found[0]!.message, /ensureLocalPlatformDeviceReady, extraReadiness/); }); -test('R76 rejects a services object widened through an expanded composition edge', () => { +test('R76 rejects a reintroduced Android-mechanics import on the selector-dispatch edge', () => { const sources = { - 'src/platform-runtime-daemon-services.ts': - 'export function createDaemonPlatformServices() { return {}; }\n' + - 'export function createProviderPlatformServices() { return {}; }\n', - 'src/daemon/server/daemon-runtime.ts': - "import {\n createDaemonPlatformServices,\n createProviderPlatformServices,\n} from '../../platform-runtime-daemon-services.ts';\n" + - 'void [createDaemonPlatformServices, createProviderPlatformServices];\n', + 'src/platform-runtime-open-target.ts': + 'export async function resolveSessionAppBundleIdForTarget() { return undefined; }\n' + + 'export async function resolveAndroidPackageForOpen() { return undefined; }\n', + 'src/daemon/handlers/session-selector-dispatch.ts': + "import { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } from '../../platform-runtime-open-target.ts';\n" + + 'void [resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget];\n', }; - const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + const found = edgeViolations(sources, 'src/daemon/handlers/session-selector-dispatch.ts'); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /classified symbols drifted/); - assert.match(found[0]!.message, /createDaemonPlatformServices, createProviderPlatformServices/); + assert.match( + found[0]!.message, + /resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget/, + ); }); test('R76 matches a destructured dynamic import by target with the recorded bindings', () => { @@ -208,13 +211,13 @@ test('R76 rejects a namespace import alongside the recorded named binding on the test('R76 treats the import and re-export of one classified pair as one entry', () => { const sources = { - [CLASSIFIED_TARGET]: CLASSIFIED_STUB, - [CLASSIFIED_IMPORTER]: - "import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" + - "export { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" + - 'void createDaemonPlatformServices;\n', + [DEVICE_READY_TARGET]: DEVICE_READY_STUB, + 'src/daemon/device/device-ready.ts': + "import { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + + "export { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + + 'void ensureLocalPlatformDeviceReady;\n', }; - assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []); + assert.deepEqual(edgeViolations(sources, 'src/daemon/device/device-ready.ts'), []); }); test('R76 ignores test-shaped and non-daemon importers', () => { diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts index acc8a3b8e0..67cc9e1be5 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -133,6 +133,16 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] '(#2168); the process root cannot carry a per-claim sessionsDir, so the scoped ' + "composition belongs to the recovery policy's own module.", }, + { + file: 'src/daemon/device/device-ready.ts', + target: 'src/platform-runtime-device-ready.ts', + symbols: ['ensureLocalPlatformDeviceReady'], + classification: 'composition-essential', + rationale: + 'neutral local-device-readiness port assembled at the root composition layer (the ' + + 'platform dispatch is internal to the root module); the daemon keeps its TTL cache ' + + 'and provider-device policy locally.', + }, { file: 'src/daemon/direct-ios-selector.ts', target: 'src/platform-runtime-apple-resources.ts', @@ -144,6 +154,54 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] 'audit deliberately adds no second selector producer.', deepenedBy: '#2273, #2274', }, + { + file: 'src/daemon/ios-app-session-hint.ts', + target: 'src/platform-runtime-apple-resources.ts', + symbols: ['appleSessionObservation'], + classification: 'daemon-policy-essential', + rationale: + 'daemon-owned hint composition and length limits consume the neutral foreground-app ' + + 'observation; the Apple package owns ambiguity and probe mechanics.', + }, + { + file: 'src/daemon/request-recording-health.ts', + target: 'src/platform-runtime-apple-resources.ts', + symbols: ['appleSessionObservation'], + classification: 'daemon-policy-essential', + rationale: + 'daemon-owned recording invalidation consumes only liveness and session identity ' + + 'through the neutral observation contract; runner mechanics stay Apple-owned.', + }, + { + file: 'src/daemon/session-device-resolution.ts', + target: 'src/platform-runtime-apple-resources.ts', + symbols: ['appleSessionObservation'], + classification: 'daemon-policy-essential', + rationale: + 'daemon-owned device refresh uses the neutral runner-session observation as boot ' + + 'evidence; inventory selection and provider exclusions remain local policy.', + }, + { + file: 'src/daemon/session-lifecycle/internal/session-open-execution.ts', + target: 'src/platform-runtime-device-boot.ts', + symbols: ['deviceBootObservation'], + classification: 'daemon-policy-essential', + rationale: + 'daemon-owned claim reconciliation asks the device when it last booted to decide whether a ' + + 'foreign claim can still describe live ownership; the per-family probe mechanics stay in the ' + + 'Apple and Android packages behind the neutral observation contract (#2538).', + }, + { + file: 'src/daemon/handlers/session-selector-dispatch.ts', + target: 'src/platform-runtime-open-target.ts', + symbols: ['resolveSessionAppBundleIdForTarget'], + classification: 'daemon-policy-essential', + rationale: + 'selector dispatch reconstructs the session app-bundle identity after a trigger-app-event ' + + 'deep link through the one neutral open-plan resolver (#2334); Android package resolution ' + + 'moved behind the Android owning seam in packages/platform-android, so the resolver is the ' + + 'only symbol this edge names.', + }, { file: 'src/daemon/server/daemon-runtime.ts', target: 'src/core/interactors.ts', @@ -178,15 +236,15 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] 'src/daemon/provider-device-admission.ts, so this edge is the composition and nothing else.', }, { - file: 'src/daemon/server/daemon-runtime.ts', - target: 'src/platform-runtime-daemon-services.ts', - symbols: ['createDaemonPlatformServices'], - classification: 'composition-essential', + file: 'src/daemon/session-lifecycle/internal/session-open-prepare.ts', + target: 'src/platform-runtime-open-target.ts', + symbols: ['resolveRequestedOpenSurface', 'validateOpenRelaunchTarget'], + classification: 'daemon-policy-essential', rationale: - 'process-root assembly of the daemon platform-services port (ADR 0019): local readiness, ' + - 'boot and runner-session observation, and open-target classification reach the request ' + - 'path only as this frozen object, so no daemon module names a platform adapter. Device ' + - 'execution never enters the port; it stays behind the request-bound runtime binding.', + 'open-prepare policy consumes only the neutral open plan/result surface (#2334): surface ' + + 'classification and relaunch-target validation. The platform mechanics that used to share ' + + 'the file (Android package resolution) moved behind the Android owning seam, leaving this ' + + 'edge daemon policy over two neutral, non-mechanics functions.', }, ] as const; diff --git a/scripts/layering/root-module-zones.ts b/scripts/layering/root-module-zones.ts index 72d3a90c2d..1821281106 100644 --- a/scripts/layering/root-module-zones.ts +++ b/scripts/layering/root-module-zones.ts @@ -52,7 +52,6 @@ export const ROOT_MODULE_ZONES: Readonly> = { 'src/platform-runtime-apple-runner-owner.ts', 'src/platform-runtime-daemon-lifecycle.ts', 'src/platform-runtime-daemon-owner-cleanup.ts', - 'src/platform-runtime-daemon-services.ts', 'src/platform-runtime-device-boot.ts', 'src/platform-runtime-device-inventory.ts', 'src/platform-runtime-device-ready.ts', diff --git a/src/cli/commands/device-release.ts b/src/cli/commands/device-release.ts index 1b2ad82c5b..2efc5fd702 100644 --- a/src/cli/commands/device-release.ts +++ b/src/cli/commands/device-release.ts @@ -2,6 +2,7 @@ import { createOwnerScopedDeviceClaimReconciler, type OwnerScopedClaimRecoveryComposer, } from '../../daemon/device/device-claim-owner-recovery.ts'; +import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts'; import { createDaemonRecoveryPlatformScope } from '../../daemon/platform-request-scope.ts'; import { releaseProvenStaleDeviceClaims, @@ -22,9 +23,10 @@ export async function runStaleDeviceClaimRelease( ): Promise { return await releaseProvenStaleDeviceClaims({ selectors, - reconcile: createOwnerScopedDeviceClaimReconciler( - createDaemonRecoveryPlatformScope(), + reconcile: createOwnerScopedDeviceClaimReconciler({ + scope: createDaemonRecoveryPlatformScope(), + composeGateway: createClaimRecoveryGateway, composeRecovery, - ), + }), }); } diff --git a/src/daemon/__tests__/human-control-router-fixture.ts b/src/daemon/__tests__/human-control-router-fixture.ts index c5c4bf97f9..ce31f103fe 100644 --- a/src/daemon/__tests__/human-control-router-fixture.ts +++ b/src/daemon/__tests__/human-control-router-fixture.ts @@ -1,5 +1,8 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from './platform-services-fixture.ts'; import { makeIosAppSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { buildSessionLeaseFromRequest } from '../lease-context.ts'; @@ -24,6 +27,7 @@ export function createHumanControlHarness() { const handleRequest = createRequestHandler({ logPath: '/tmp/agent-device-human-control.log', platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), token: 'test-token', sessionStore, leaseRegistry: registry, diff --git a/src/daemon/__tests__/platform-services-fixture.ts b/src/daemon/__tests__/platform-services-fixture.ts index 538cac8bd7..0121a9c7a2 100644 --- a/src/daemon/__tests__/platform-services-fixture.ts +++ b/src/daemon/__tests__/platform-services-fixture.ts @@ -1,5 +1,7 @@ import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts'; +import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts'; import type { DaemonPlatformServices } from '../platform-services.ts'; +import type { ClaimRecoveryGatewayFactory } from '../device/claim-recovery-gateway.ts'; /** * The port the production composition root builds, handed to a test the same way the shared router @@ -13,3 +15,12 @@ export function daemonPlatformServicesFixture( ): DaemonPlatformServices { return Object.freeze({ ...createDaemonPlatformServices(), ...overrides }); } + +/** + * The recovery-gateway half of root composition, same rule as the services above: tests that + * route a stale claim see the real composed gateway, and a test that cares about composition + * passes its own factory. + */ +export function daemonClaimRecoveryGatewayFixture(): ClaimRecoveryGatewayFactory { + return createClaimRecoveryGateway; +} diff --git a/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts b/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts index 3809c50bd6..a99e3c3dcb 100644 --- a/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts +++ b/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts @@ -1,5 +1,8 @@ import { expect, test, vi } from 'vitest'; -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from './platform-services-fixture.ts'; import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { LINUX_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; @@ -47,6 +50,7 @@ test('expired leases remove owned sessions before the next command and free capa const scope = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -101,6 +105,7 @@ test.each(['rebuild', 'retire'] as const)( now = 1_011; const scope = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ session: address, flags: { session: address } }), sessionStore: store, leaseRegistry: leases, diff --git a/src/daemon/__tests__/request-execution-scope-macos-app-lease.test.ts b/src/daemon/__tests__/request-execution-scope-macos-app-lease.test.ts index 83034aa6af..27052c4e23 100644 --- a/src/daemon/__tests__/request-execution-scope-macos-app-lease.test.ts +++ b/src/daemon/__tests__/request-execution-scope-macos-app-lease.test.ts @@ -1,5 +1,8 @@ import { expect, test } from 'vitest'; -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from './platform-services-fixture.ts'; import { makeMacOsSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { parseDaemonPolicy } from '../../daemon-policy-file.ts'; @@ -39,6 +42,7 @@ async function runScoped(params: { }): Promise<{ ran: boolean; error?: unknown }> { const scope = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: { token: 't', session: 'default', positionals: [], ...params.req }, sessionStore: params.sessionStore, leaseRegistry: params.leaseRegistry, @@ -121,6 +125,7 @@ test('a request under a pid-pinned macos-app lease does not run once that proces ); const scope = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: { token: 't', session: 'default', command: 'snapshot', positionals: [], flags: {} }, sessionStore, leaseRegistry, diff --git a/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts b/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts index 82ec94ee4b..f8418aca8a 100644 --- a/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts +++ b/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts @@ -1,5 +1,8 @@ import { afterAll, test, expect, vi } from 'vitest'; -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from './platform-services-fixture.ts'; import fs from 'node:fs'; import { getFlagDefinitionsForKey } from '@agent-device/command-registry/flag-registry'; import type { CommandFlags } from '@agent-device/contracts/command'; @@ -117,12 +120,14 @@ test('an open that lost the race to a free device re-waits and opens rather than const first = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: openRequest('first-opener'), sessionStore, leaseRegistry, }); const second = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: openRequest('second-opener'), sessionStore, leaseRegistry, @@ -156,6 +161,7 @@ test('a close that frees the device mid-wait gets through while the open is wait const opened = createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: openRequest('waiter'), sessionStore, leaseRegistry, @@ -169,6 +175,7 @@ test('a close that frees the device mid-wait gets through while the open is wait await sleep(50); const closer = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: closeRequest('holder'), sessionStore, leaseRegistry, @@ -201,6 +208,7 @@ test('an out-of-range wait budget is refused before resolving the target device' await expect( createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: openRequest('unbounded-opener', max + 1, 'request-unbounded'), sessionStore: makeSessionStore('agent-device-open-wait-bounds-'), leaseRegistry: new LeaseRegistry(), diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index 3aeea64bc6..f674b71a7b 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -1,4 +1,7 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from './platform-services-fixture.ts'; import { afterAll, test, expect, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -889,11 +892,15 @@ test('router: an existing lease-less session under tenant isolation still refuse }); function createTestRequestScope( - params: Omit[0], 'platformServices'>, + params: Omit< + Parameters[0], + 'platformServices' | 'claimRecoveryGateway' + >, ) { return createRequestExecutionScope({ ...params, platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), }); } diff --git a/src/daemon/__tests__/request-recording-health.test.ts b/src/daemon/__tests__/request-recording-health.test.ts index bb5f520411..c731c83dfe 100644 --- a/src/daemon/__tests__/request-recording-health.test.ts +++ b/src/daemon/__tests__/request-recording-health.test.ts @@ -1,4 +1,7 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from './platform-services-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import type { SessionState } from '../session-state.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; @@ -201,7 +204,8 @@ test.each(['rebuild', 'retire'] as const)( leaseRegistry: new LeaseRegistry(), // Locked preparation refreshes recording health through the port, so the scope carries // the fixture whose observation member is the test's spy. - platformServices: platformServices, + platformServices, + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), }); const prepared = scope.runLocked(() => prepareLockedRequestScope({ diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index d90ded8607..405d701375 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -1,5 +1,8 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from './platform-services-fixture.ts'; import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; @@ -491,6 +494,7 @@ test('close fails synchronously when root composition omits platform resource cl const handler = createProductionRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), diff --git a/src/daemon/__tests__/session-idle-activity.test.ts b/src/daemon/__tests__/session-idle-activity.test.ts index ef0242cc2b..9577fae2af 100644 --- a/src/daemon/__tests__/session-idle-activity.test.ts +++ b/src/daemon/__tests__/session-idle-activity.test.ts @@ -8,7 +8,10 @@ * nothing, exactly as a canceled request renews no lease (ADR 0007). */ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from './platform-services-fixture.ts'; import { afterAll, test, expect, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -57,6 +60,7 @@ test('a command that completes under the session lock stamps the session it ran const scope = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, () => createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -73,6 +77,7 @@ test('a lock-exempt inventory command reports no activity, even against a claim- const scope = await withDiagnosticsScope({ command: 'devices', logPath: LOG_PATH }, () => createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'devices' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -94,6 +99,7 @@ test('a command whose client hung up mid-flight preserves no activity', async () () => createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'snapshot', meta: { requestId } }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -122,6 +128,7 @@ test('a command still running when the expiry arrives blocks it until the stamp const scope = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, () => createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -173,6 +180,7 @@ test('the reaper never observes a mid-command session through the shared map', a const scope = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, () => createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index b66bf6081d..39076eccb8 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -26,6 +26,7 @@ import { withClientReplayScriptSources } from '../../__tests__/test-utils/replay import type { DaemonInvokeFn } from '../daemon-request.ts'; import { clearAndroidObservationFixture } from './android-observation-fixture.ts'; import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts'; import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; import { readDaemonProviderCredentials } from '../../provider-credential-fingerprint.ts'; @@ -225,16 +226,20 @@ export const unavailableBindExactDevice: BindExactDeviceRuntime = async ( export function createRequestHandler( deps: Omit< RequestRouterDeps, - 'deviceRuntimeGateway' | 'providerCredentials' | 'platformServices' + 'deviceRuntimeGateway' | 'providerCredentials' | 'platformServices' | 'claimRecoveryGateway' > & Partial< - Pick + Pick< + RequestRouterDeps, + 'deviceRuntimeGateway' | 'providerCredentials' | 'platformServices' | 'claimRecoveryGateway' + > >, ) { const { deviceRuntimeGateway = unavailableDeviceRuntimeGateway, providerCredentials = readDaemonProviderCredentials({}, path.dirname(deps.logPath)), platformServices = daemonPlatformServicesFixture(), + claimRecoveryGateway = createClaimRecoveryGateway, ...rest } = deps; const handle = createProductionRequestHandler({ @@ -242,6 +247,7 @@ export function createRequestHandler( platformResourceCleanup, ...rest, platformServices, + claimRecoveryGateway, deviceRuntimeGateway, providerCredentials, }); diff --git a/src/daemon/device/__tests__/device-claim-admission.test.ts b/src/daemon/device/__tests__/device-claim-admission.test.ts index 2ebad41d23..4888198782 100644 --- a/src/daemon/device/__tests__/device-claim-admission.test.ts +++ b/src/daemon/device/__tests__/device-claim-admission.test.ts @@ -1,5 +1,8 @@ import { expect, test } from 'vitest'; -import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from '../../__tests__/platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { localRuntimeOwner, providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; @@ -218,6 +221,7 @@ test('the none policy still reaches no device state at all', async () => { async function claimsWhileBound(command: string, stateDir: string) { const scope = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: { token: 't', session: 'default', command, positionals: [], flags: {} }, sessionStore: new SessionStore(path.join(stateDir, 'sessions')), leaseRegistry: new LeaseRegistry(), diff --git a/src/daemon/device/__tests__/device-claim-owner-recovery.test.ts b/src/daemon/device/__tests__/device-claim-owner-recovery.test.ts index 5e992af067..b575d84792 100644 --- a/src/daemon/device/__tests__/device-claim-owner-recovery.test.ts +++ b/src/daemon/device/__tests__/device-claim-owner-recovery.test.ts @@ -1,7 +1,15 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import { test } from 'vitest'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import type { AppLogSessionArtifacts } from '@agent-device/contracts/app-log-runtime'; +import type { OwnedProcessRecordWriter } from '@agent-device/contracts/platform-runtime-host'; import type { DeviceClaim } from '../device-claim-record.ts'; -import { createOwnerScopedDeviceClaimReconciler } from '../device-claim-owner-recovery.ts'; +import { + createOwnerScopedDeviceClaimReconciler, + type OwnerScopedClaimRecoveryComposer, +} from '../device-claim-owner-recovery.ts'; const scope = { signal: new AbortController().signal, @@ -25,18 +33,27 @@ function makeClaim(stateDir: string, session: string): DeviceClaim { }; } +const composerAs = (compose: OwnerScopedClaimRecoveryComposer): OwnerScopedClaimRecoveryComposer => + compose; + test('composes one recovery per claim from that claim state dir and disposes it', async () => { const events: string[] = []; - const reconcile = createOwnerScopedDeviceClaimReconciler(scope, (stateDir) => ({ - reconcile: async (claim) => { - assert.equal(claim.stateDir, stateDir); - events.push(`reconcile:${stateDir}`); - return { status: 'reconciled' }; - }, - dispose: async () => { - events.push(`dispose:${stateDir}`); + const reconcile = createOwnerScopedDeviceClaimReconciler({ + scope, + composeGateway: () => { + throw new Error('test composes through composeRecovery'); }, - })); + composeRecovery: composerAs((stateDir) => ({ + reconcile: async (claim) => { + assert.equal(claim.stateDir, stateDir); + events.push(`reconcile:${stateDir}`); + return { status: 'reconciled' }; + }, + dispose: async () => { + events.push(`dispose:${stateDir}`); + }, + })), + }); await reconcile(makeClaim('/state/owner-a', 'shared')); await reconcile(makeClaim('/state/owner-b', 'shared')); @@ -51,26 +68,90 @@ test('composes one recovery per claim from that claim state dir and disposes it' test('disposes the composed recovery when reconciliation retains or throws', async () => { const disposed: string[] = []; - const retainReconcile = createOwnerScopedDeviceClaimReconciler(scope, (stateDir) => ({ - reconcile: async () => ({ status: 'retained', reason: 'cleanup-pending' }), - dispose: async () => { - disposed.push(stateDir); + const retainReconcile = createOwnerScopedDeviceClaimReconciler({ + scope, + composeGateway: () => { + throw new Error('test composes through composeRecovery'); }, - })); + composeRecovery: composerAs((stateDir) => ({ + reconcile: async () => ({ status: 'retained', reason: 'cleanup-pending' }), + dispose: async () => { + disposed.push(stateDir); + }, + })), + }); const result = await retainReconcile(makeClaim('/state/retained', 'shared')); assert.deepEqual(result, { status: 'retained', reason: 'cleanup-pending' }); - const throwingReconcile = createOwnerScopedDeviceClaimReconciler(scope, (stateDir) => ({ - reconcile: async () => { - throw new Error('recovery exploded'); - }, - dispose: async () => { - disposed.push(stateDir); + const throwingReconcile = createOwnerScopedDeviceClaimReconciler({ + scope, + composeGateway: () => { + throw new Error('test composes through composeRecovery'); }, - })); + composeRecovery: composerAs((stateDir) => ({ + reconcile: async () => { + throw new Error('recovery exploded'); + }, + dispose: async () => { + disposed.push(stateDir); + }, + })), + }); await assert.rejects( async () => await throwingReconcile(makeClaim('/state/thrown', 'shared')), /recovery exploded/, ); assert.deepEqual(disposed, ['/state/retained', '/state/thrown']); }); + +// #2168: recovery has to rebuild the DEAD owner's world. The gateway the default composition +// assembles therefore has to be fed the claim's own paths — a gateway built from the reconciling +// daemon's paths would clear a same-named live session's owned-process records. +test('the default composition hands the gateway factory the claim owner state dir', async () => { + const ownerA = mkdtempForTestSync('agent-device-claim-owner-a-'); + const ownerB = mkdtempForTestSync('agent-device-claim-owner-b-'); + const composed: { + sessionsDir: string; + ownedProcesses?: OwnedProcessRecordWriter; + resolveSessionArtifacts: (sessionId: string) => AppLogSessionArtifacts; + }[] = []; + const shutdowns: string[] = []; + const reconcile = createOwnerScopedDeviceClaimReconciler({ + scope, + composeGateway: (input) => { + composed.push(input); + return { + shutdown: async () => { + shutdowns.push(input.sessionsDir); + }, + } as never; + }, + }); + + const outcomeA = await reconcile(makeClaim(ownerA, 'shared')); + const outcomeB = await reconcile(makeClaim(ownerB, 'shared')); + + assert.deepEqual([outcomeA, outcomeB], [{ status: 'reconciled' }, { status: 'reconciled' }]); + // Paths are derived per transaction from the CLAIM's state dir, so a same-named session under + // another owner never shares a store. Writing through each composed store and asserting where + // the record lands pins the #2168 hazard: a store bound to the reconciling process's dirs (or + // to the first claim's) would write under the wrong owner's session dir. + assert.deepEqual( + composed.map((input) => input.sessionsDir), + [path.join(ownerA, 'sessions'), path.join(ownerB, 'sessions')], + ); + const record = { pid: 4242, startTime: 'dead-start', command: 'adb', purpose: 'test' } as const; + composed.forEach((input, index) => { + input.ownedProcesses!.replace({ kind: 'session', sessionId: 'shared' }, [record]); + const owner = index === 0 ? ownerA : ownerB; + assert.equal( + fs.existsSync(path.join(owner, 'sessions', 'shared', 'owned-processes.json')), + true, + `owned-process record for session 'shared' must land under the claim owner's dir (${owner})`, + ); + }); + assert.notEqual(composed[0]!.sessionsDir, composed[1]!.sessionsDir); + assert.ok(composed.every((input) => typeof input.resolveSessionArtifacts === 'function')); + // Disposal follows composition, so a later claim never runs on an earlier owner's gateway. + assert.deepEqual(shutdowns, [path.join(ownerA, 'sessions'), path.join(ownerB, 'sessions')]); +}); diff --git a/src/daemon/device/claim-recovery-gateway.ts b/src/daemon/device/claim-recovery-gateway.ts new file mode 100644 index 0000000000..9c0a8b89af --- /dev/null +++ b/src/daemon/device/claim-recovery-gateway.ts @@ -0,0 +1,25 @@ +import type { AppLogSessionArtifacts } from '@agent-device/contracts/app-log-runtime'; +import type { OwnedProcessRecordWriter } from '@agent-device/contracts/platform-runtime-host'; +import type { DeviceRuntimeGateway } from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; + +/** + * The per-transaction inputs a stale-claim recovery gateway is built from. Every path names the + * DEAD owner's state dir, never the reconciling process's: recording cleanup clears owned-process + * records by bare session id through the composed store, so one caller-scoped store would let a + * foreign claim's recovery clear a same-named live session's records (#2168). + */ +export type ClaimRecoveryGatewayInput = Readonly<{ + sessionsDir: string; + ownedProcesses?: OwnedProcessRecordWriter; + resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; +}>; + +/** + * The one gateway act the daemon asks root composition to perform for it: assemble the local + * runtime gateway a recovery transaction runs on. Building a gateway names platform modules, so + * the daemon declares only this question and never answers it (ADR 0019). + */ +export type ClaimRecoveryGatewayFactory = ( + input: ClaimRecoveryGatewayInput, +) => DeviceRuntimeGateway; diff --git a/src/daemon/device/device-claim-owner-recovery.ts b/src/daemon/device/device-claim-owner-recovery.ts index f57a6c641f..977561e2df 100644 --- a/src/daemon/device/device-claim-owner-recovery.ts +++ b/src/daemon/device/device-claim-owner-recovery.ts @@ -1,9 +1,9 @@ import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import { createOwnedProcessRecordStore } from '@agent-device/host-kit/process'; -import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; import { resolveDaemonPaths } from '../../daemon-resolution.ts'; import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; import type { DeviceClaimReconciler } from './device-claims.ts'; +import type { ClaimRecoveryGatewayFactory } from './claim-recovery-gateway.ts'; import { resolveSessionDir, resolveSessionAppLogPath, @@ -20,18 +20,29 @@ export type OwnerScopedClaimRecoveryComposer = (stateDir: string) => OwnerScoped /** * The production entry for stale-claim reconciliation (#2168): every recovery * transaction is composed from the stale claim's own recorded state dir and - * disposed afterwards. The owned-process record store and session artifact - * paths must be the dead owner's, never the reconciling process's — recording - * cleanup clears records by bare session id through the composed store, and - * one caller-scoped store would let a foreign claim's recovery clear a - * same-named live session's records. + * disposed afterwards. The owned-process record store, session artifact paths, + * and runtime gateway must be the dead owner's, never the reconciling + * process's — recording cleanup clears records by bare session id through the + * composed store, and one caller-scoped store would let a foreign claim's + * recovery clear a same-named live session's records. + * + * The gateway itself arrives through `composeGateway`, which root composition + * supplies: assembling a runtime gateway is the process root's act, and this + * module keeps only the transaction — what to rebuild from, in what order, and + * when to dispose it. */ export function createOwnerScopedDeviceClaimReconciler( - scope: PlatformRequestScope, - composeRecovery?: OwnerScopedClaimRecoveryComposer, + params: Readonly<{ + scope: PlatformRequestScope; + /** Root composition's per-transaction recovery gateway; used unless `composeRecovery` replaces it. */ + composeGateway: ClaimRecoveryGatewayFactory; + /** Test seam replacing the whole per-claim composition, gateway included. */ + composeRecovery?: OwnerScopedClaimRecoveryComposer; + }>, ): DeviceClaimReconciler { const compose = - composeRecovery ?? ((stateDir) => composeOwnerScopedClaimRecovery(stateDir, scope)); + params.composeRecovery ?? + ((stateDir) => composeOwnerScopedClaimRecovery(stateDir, params.scope, params.composeGateway)); return async (claim) => { const recovery = compose(claim.stateDir); try { @@ -51,9 +62,10 @@ export function createOwnerScopedDeviceClaimReconciler( function composeOwnerScopedClaimRecovery( stateDir: string, scope: PlatformRequestScope, + composeGateway: ClaimRecoveryGatewayFactory, ): OwnerScopedClaimRecovery { const daemonPaths = resolveDaemonPaths(stateDir); - const gateway = createPlatformRuntimeGateway({ + const gateway = composeGateway({ sessionsDir: daemonPaths.sessionsDir, ownedProcesses: createOwnedProcessRecordStore({ stateDir: daemonPaths.baseDir, diff --git a/src/daemon/handlers/__tests__/human-control.test.ts b/src/daemon/handlers/__tests__/human-control.test.ts index 2463a74190..d122846450 100644 --- a/src/daemon/handlers/__tests__/human-control.test.ts +++ b/src/daemon/handlers/__tests__/human-control.test.ts @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; -import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from '../../__tests__/platform-services-fixture.ts'; import { test } from 'vitest'; import { createRequestExecutionScope } from '../../request-execution-scope.ts'; import { @@ -33,6 +36,7 @@ test('lease-owner takeover uses the production admission gate, preserves the ses assert.deepEqual(registry.listHumanControlHolds({ kind: 'host' }), []); const scope = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: humanControlRequest(lease, 'click', []), sessionStore, leaseRegistry: registry, @@ -89,6 +93,7 @@ test('a fresh-session mutation without an advisory device lock still drains befo sessionStore, leaseRegistry: registry, platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), }); const started = createControlLatch(); const finish = createControlLatch(); @@ -107,6 +112,7 @@ test('a fresh-session mutation without an advisory device lock still drains befo assert.equal(active, false); const later = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: { ...req, session: 'another-fresh-session' }, sessionStore, leaseRegistry: registry, @@ -129,6 +135,7 @@ test('a nested mutation is stopped when takeover begins during its parent reques sessionStore, leaseRegistry: registry, platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), }); let activation: Promise | undefined; await scope.runLocked(async () => { @@ -137,6 +144,7 @@ test('a nested mutation is stopped when takeover begins during its parent reques }); const nested = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: humanControlRequest(lease, 'click', []), sessionStore, leaseRegistry: registry, diff --git a/src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts b/src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts index be2e1142b7..dbe974659b 100644 --- a/src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts @@ -1,4 +1,7 @@ -import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from '../../__tests__/platform-services-fixture.ts'; import { expect, test } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -69,6 +72,7 @@ async function runStateCommand( leaseRegistry: new LeaseRegistry(), deviceRuntimeGateway: readinessDeviceRuntimeGateway, platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), platformRequestScope: { signal: new AbortController().signal, diagnostics: { emit: () => {} }, diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index d1c7cac1f1..ec083ddfcc 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -61,6 +61,7 @@ import { type DeviceClaimAdmission, } from './device/device-claim-admission.ts'; import { createOwnerScopedDeviceClaimReconciler } from './device/device-claim-owner-recovery.ts'; +import type { ClaimRecoveryGatewayFactory } from './device/claim-recovery-gateway.ts'; import { applyCommandDefaults, resolveCommandDeviceClaimPolicy, @@ -144,6 +145,7 @@ export async function createRequestExecutionScope(params: { leaseRegistry: LeaseRegistry; deviceRuntimeGateway?: DeviceRuntimeGateway; platformServices: DaemonPlatformServices; + claimRecoveryGateway: ClaimRecoveryGatewayFactory; platformRequestScope?: PlatformRequestScope; platformResourceCleanup?: PlatformResourceCleanup; providerAppCatalog?: ProviderAppCatalog; @@ -236,6 +238,7 @@ export async function createRequestExecutionScope(params: { deviceRuntimeGateway: params.deviceRuntimeGateway, platformRequestScope: params.platformRequestScope, daemonPolicy: params.daemonPolicy, + claimRecoveryGateway: params.claimRecoveryGateway, }); const scope: RequestExecutionScope = { @@ -369,6 +372,7 @@ function createRequestDeviceAccess(params: { deviceRuntimeGateway: DeviceRuntimeGateway | undefined; platformRequestScope: PlatformRequestScope | undefined; daemonPolicy: DaemonPolicy | undefined; + claimRecoveryGateway: ClaimRecoveryGatewayFactory; }): { claimAdmission: DeviceClaimAdmission | undefined; runtimeBindings: RequestRuntimeBindings | undefined; @@ -382,7 +386,10 @@ function createRequestDeviceAccess(params: { command: params.command, workspace: params.workspace, stateDir: params.stateDir, - reconcileOrphanedDeviceClaim: createOwnerScopedDeviceClaimReconciler(platformRequestScope), + reconcileOrphanedDeviceClaim: createOwnerScopedDeviceClaimReconciler({ + scope: platformRequestScope, + composeGateway: params.claimRecoveryGateway, + }), }); return { claimAdmission, diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index e22e2ed9f3..9e1f6e9fae 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -63,6 +63,7 @@ import { import { createPlatformRequestScope } from './platform-request-scope.ts'; import { parameterizeSensitiveString } from '@agent-device/selectors/parameterized-recorded-fill'; import { createOwnerScopedDeviceClaimReconciler } from './device/device-claim-owner-recovery.ts'; +import type { ClaimRecoveryGatewayFactory } from './device/claim-recovery-gateway.ts'; import { isConfinedToAppLease, scopeRequestSession } from './request-admission.ts'; import { redactMacOsAppLeaseResponse } from './macos-app-lease.ts'; import { resolveEffectiveSessionName } from './session-routing.ts'; @@ -113,6 +114,12 @@ export type RequestRouterDeps = { * process that never ran root composition cannot build one, and no daemon module may. */ platformServices: DaemonPlatformServices; + /** + * Root composition's per-transaction claim-recovery gateway (#2168). Rebuilding the dead owner's + * gateway is the act that names platform modules, so the router holds only this factory and + * passes it to every reconciler it builds; only root composition can construct a router. + */ + claimRecoveryGateway: ClaimRecoveryGatewayFactory; appLogAdmissionLedger?: AppLogAdmissionLedger; audioProbeAdmissionLedger?: AudioProbeAdmissionLedger; perfCaptureAdmissionLedger?: PerfCaptureAdmissionLedger; @@ -172,6 +179,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { requestPlatformProviders = EMPTY_REQUEST_PLATFORM_PROVIDERS, deviceRuntimeGateway, platformServices, + claimRecoveryGateway, appLogAdmissionLedger = createAppLogAdmissionLedger(), audioProbeAdmissionLedger = createAudioProbeAdmissionLedger(), perfCaptureAdmissionLedger = createPerfCaptureAdmissionLedger(), @@ -263,6 +271,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseRegistry, deviceRuntimeGateway, platformServices, + claimRecoveryGateway, platformRequestScope, platformResourceCleanup, providerAppCatalog, @@ -379,7 +388,10 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { platformServices: lockedScope.platformServices, inspectFacts: lockedScope.inspectFacts, bindExactDevice: lockedScope.bindExactDevice, - reconcileOrphanedDeviceClaim: createOwnerScopedDeviceClaimReconciler(requestScope), + reconcileOrphanedDeviceClaim: createOwnerScopedDeviceClaimReconciler({ + scope: requestScope, + composeGateway: claimRecoveryGateway, + }), appLogAdmissionLedger, audioProbeAdmissionLedger, perfCaptureAdmissionLedger, @@ -421,6 +433,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseRegistry, deviceRuntimeGateway, platformServices, + claimRecoveryGateway, platformRequestScope: createPlatformRequestScope(scopedReq), platformResourceCleanup, providerAppCatalog, diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 1c9dc1a259..4826b3b6b6 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -411,11 +411,13 @@ export async function startDaemonRuntime( const deviceInventoryGateways = createPlatformDeviceInventoryGateways( providerRuntimeProviders.deviceInventorySource, ); - // Root composition of the platform-services port: it names concrete platform adapters, so it - // loads here — the process root's own startup — rather than riding the daemon entry's eager - // closure, and it stays lazy to the entry's import graph. + // Root composition of the platform-services port and the owner-scoped claim-recovery gateway. + // platform-runtime.ts is already static in this file, so the deferred imports keep these thin + // composition modules (and the readiness/open-target adapters only the services module pulls) + // off `src/daemon.ts`'s eager closure; what each adapter itself loads stays as lazy as it was. const { createDaemonPlatformServices } = await import('../../platform-runtime-daemon-services.ts'); + const { createClaimRecoveryGateway } = await import('../../platform-runtime-claim-recovery.ts'); const dispatchRequest = createRequestHandler({ logPath, @@ -428,6 +430,7 @@ export async function startDaemonRuntime( deviceInventoryGateways, deviceRuntimeGateway, platformServices: createDaemonPlatformServices(), + claimRecoveryGateway: createClaimRecoveryGateway, appLogAdmissionLedger, audioProbeAdmissionLedger, perfCaptureAdmissionLedger, @@ -722,7 +725,10 @@ export async function startDaemonRuntime( // written before it is lost — including reconciliation diagnostics. await reconcileDeviceClaimsForDaemonStartup( logPath, - createOwnerScopedDeviceClaimReconciler(createDaemonRecoveryPlatformScope()), + createOwnerScopedDeviceClaimReconciler({ + scope: createDaemonRecoveryPlatformScope(), + composeGateway: createClaimRecoveryGateway, + }), baseDir, ); // Arms the initial idle-reap timer: a daemon that starts and never diff --git a/src/platform-runtime-claim-recovery.ts b/src/platform-runtime-claim-recovery.ts new file mode 100644 index 0000000000..b1f3b53a17 --- /dev/null +++ b/src/platform-runtime-claim-recovery.ts @@ -0,0 +1,17 @@ +import { createPlatformRuntimeGateway } from './platform-runtime.ts'; +import type { ClaimRecoveryGatewayFactory } from './daemon/device/claim-recovery-gateway.ts'; + +/** + * Root composition for owner-scoped claim recovery (ADR 0019 section 1): the daemon owns the + * recovery transaction and its disposal, and asks root composition only to assemble the local + * runtime gateway the transaction runs on, because building a gateway is the one act that names + * platform modules. A local-only gateway per transaction keeps provider runtimes out of recovery — + * claims exist only for local devices — and instance-scopes every stateful piece the gateway owns, + * so disposal shuts down only this transaction's handles. + */ +export const createClaimRecoveryGateway: ClaimRecoveryGatewayFactory = (input) => + createPlatformRuntimeGateway({ + sessionsDir: input.sessionsDir, + ownedProcesses: input.ownedProcesses, + resolveSessionArtifacts: input.resolveSessionArtifacts, + }); diff --git a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts index 618d27210b..af33d3da68 100644 --- a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts +++ b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts @@ -1,5 +1,8 @@ // Real-scope same-device serialization guard for the concurrency torture lane -import { daemonPlatformServicesFixture } from '../../../../src/daemon/__tests__/platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + daemonClaimRecoveryGatewayFixture, +} from '../../../../src/daemon/__tests__/platform-services-fixture.ts'; // (#1416). The seeded sweep models the mutex GRANT (a seed cannot reproduce // `withKeyedLock`'s native microtask hand-off), so on its own it could stay // green if the PRODUCTION lock APPLICATION path regressed. This guard closes @@ -67,6 +70,7 @@ export async function measureRealScopeMaxOverlap(concurrency: number): Promise createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: openRequest(`real-${i}`, device.id), sessionStore, leaseRegistry, diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index b54de2a458..36fd7fe004 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -16,6 +16,7 @@ import { androidObservation, type PlatformProviderResolvers, } from '../../../src/platform-runtime.ts'; +import { createClaimRecoveryGateway } from '../../../src/platform-runtime-claim-recovery.ts'; import { createDaemonPlatformServices } from '../../../src/platform-runtime-daemon-services.ts'; import { platformResourceCleanup } from '../../../src/platform-runtime-resource-cleanup.ts'; import { readDaemonProviderCredentials } from '../../../src/provider-credential-fingerprint.ts'; @@ -181,6 +182,7 @@ export async function createProviderScenarioHarness( androidObservation, platformResourceCleanup, platformServices: createDaemonPlatformServices(), + claimRecoveryGateway: createClaimRecoveryGateway, requestPlatformProviders: configuredRequestPlatformProviders ?? createRequestPlatformProviders({ From 7584bf2feae5278e89122071997bf0577fa705ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 00:09:40 +0200 Subject: [PATCH 2/2] chore(gates): classify the claim-recovery gateway composition edge R76 inventory gains the daemon-runtime -> platform-runtime-claim-recovery edge and retires the superseded device-claim-owner-recovery -> platform-runtime entry; the root-module zone list and planted-violation fixtures follow. --- .../daemon-platform-runtime-inventory.test.ts | 77 ++++++++------- .../daemon-platform-runtime-inventory.ts | 94 ++++--------------- scripts/layering/root-module-zones.ts | 2 + 3 files changed, 57 insertions(+), 116 deletions(-) diff --git a/scripts/layering/daemon-platform-runtime-inventory.test.ts b/scripts/layering/daemon-platform-runtime-inventory.test.ts index 2bd73cd8f0..e58b87f4de 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.test.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.test.ts @@ -8,9 +8,11 @@ import { isRootPlatformRuntimeTarget, } from './daemon-platform-runtime-inventory.ts'; -const DEVICE_READY_TARGET = 'src/platform-runtime-device-ready.ts'; -const DEVICE_READY_STUB = - 'export async function ensureLocalPlatformDeviceReady(device: unknown) { return false; }\n'; +// The classified pair these fixtures plant is the claim-recovery gateway factory: the composition +// edge this port introduced, and the shortest one whose symbols the rule still classifies. +const CLASSIFIED_TARGET = 'src/platform-runtime-claim-recovery.ts'; +const CLASSIFIED_STUB = 'export const createClaimRecoveryGateway = () => ({});\n'; +const CLASSIFIED_IMPORTER = 'src/daemon/server/daemon-runtime.ts'; function violations(sources: Record): LayeringViolation[] { return checkDaemonPlatformRuntimeInventory(resolveImportEdges(new Map(Object.entries(sources)))); @@ -22,12 +24,12 @@ function edgeViolations(sources: Record, file: string): Layering test('R76 accepts a classified edge with the exact recorded symbols', () => { const sources = { - [DEVICE_READY_TARGET]: DEVICE_READY_STUB, - 'src/daemon/device/device-ready.ts': - "import { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + - 'void ensureLocalPlatformDeviceReady;\n', + [CLASSIFIED_TARGET]: CLASSIFIED_STUB, + [CLASSIFIED_IMPORTER]: + "import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" + + 'void createClaimRecoveryGateway;\n', }; - assert.deepEqual(edgeViolations(sources, 'src/daemon/device/device-ready.ts'), []); + assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []); }); for (const [file, target, symbol] of [ @@ -50,20 +52,18 @@ for (const [file, target, symbol] of [ test('R76 reports every classified edge missing from the tree as stale, not the other way around', () => { const sources = { - [DEVICE_READY_TARGET]: DEVICE_READY_STUB, - 'src/daemon/device/device-ready.ts': - "import { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + - 'void ensureLocalPlatformDeviceReady;\n', + [CLASSIFIED_TARGET]: CLASSIFIED_STUB, + [CLASSIFIED_IMPORTER]: + "import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" + + 'void createClaimRecoveryGateway;\n', }; const stale = violations(sources).filter( (violation) => violation.file === 'scripts/layering/daemon-platform-runtime-inventory.ts', ); assert.equal(stale.length, DAEMON_PLATFORM_RUNTIME_EDGES.length - 1); assert.ok(stale.every((violation) => violation.message.includes('stale classified edge'))); - const deviceReadyStale = stale.find((violation) => - violation.message.includes(DEVICE_READY_TARGET), - ); - assert.equal(deviceReadyStale, undefined); + const classifiedStale = stale.find((violation) => violation.message.includes(CLASSIFIED_TARGET)); + assert.equal(classifiedStale, undefined); }); test('R76 rejects an unclassified edge with the pair and its line', () => { @@ -86,35 +86,32 @@ test('R76 rejects an unclassified edge with the pair and its line', () => { test('R76 rejects new symbols on a classified edge', () => { const sources = { - [DEVICE_READY_TARGET]: DEVICE_READY_STUB + 'export function extraReadiness() {}\n', - 'src/daemon/device/device-ready.ts': - "import { ensureLocalPlatformDeviceReady, extraReadiness } from '../../platform-runtime-device-ready.ts';\n" + - 'void [ensureLocalPlatformDeviceReady, extraReadiness];\n', + [CLASSIFIED_TARGET]: CLASSIFIED_STUB + 'export function extraGatewayKnob() {}\n', + [CLASSIFIED_IMPORTER]: + "import { createClaimRecoveryGateway, extraGatewayKnob } from '../../platform-runtime-claim-recovery.ts';\n" + + 'void [createClaimRecoveryGateway, extraGatewayKnob];\n', }; - const found = edgeViolations(sources, 'src/daemon/device/device-ready.ts'); + const found = edgeViolations(sources, CLASSIFIED_IMPORTER); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /classified symbols drifted/); - assert.match(found[0]!.message, /ensureLocalPlatformDeviceReady, extraReadiness/); + assert.match(found[0]!.message, /createClaimRecoveryGateway, extraGatewayKnob/); }); -test('R76 rejects a reintroduced Android-mechanics import on the selector-dispatch edge', () => { +test('R76 rejects a gateway assembled from an expanded recovery edge', () => { const sources = { - 'src/platform-runtime-open-target.ts': - 'export async function resolveSessionAppBundleIdForTarget() { return undefined; }\n' + - 'export async function resolveAndroidPackageForOpen() { return undefined; }\n', - 'src/daemon/handlers/session-selector-dispatch.ts': - "import { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } from '../../platform-runtime-open-target.ts';\n" + - 'void [resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget];\n', + 'src/platform-runtime-claim-recovery.ts': + 'export const createClaimRecoveryGateway = () => ({});\n' + + 'export const createProviderRecoveryGateway = () => ({});\n', + 'src/daemon/server/daemon-runtime.ts': + "import {\n createClaimRecoveryGateway,\n createProviderRecoveryGateway,\n} from '../../platform-runtime-claim-recovery.ts';\n" + + 'void [createClaimRecoveryGateway, createProviderRecoveryGateway];\n', }; - const found = edgeViolations(sources, 'src/daemon/handlers/session-selector-dispatch.ts'); + const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /classified symbols drifted/); - assert.match( - found[0]!.message, - /resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget/, - ); + assert.match(found[0]!.message, /createClaimRecoveryGateway, createProviderRecoveryGateway/); }); test('R76 matches a destructured dynamic import by target with the recorded bindings', () => { @@ -211,13 +208,13 @@ test('R76 rejects a namespace import alongside the recorded named binding on the test('R76 treats the import and re-export of one classified pair as one entry', () => { const sources = { - [DEVICE_READY_TARGET]: DEVICE_READY_STUB, - 'src/daemon/device/device-ready.ts': - "import { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + - "export { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + - 'void ensureLocalPlatformDeviceReady;\n', + [CLASSIFIED_TARGET]: CLASSIFIED_STUB, + [CLASSIFIED_IMPORTER]: + "import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" + + "export { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" + + 'void createClaimRecoveryGateway;\n', }; - assert.deepEqual(edgeViolations(sources, 'src/daemon/device/device-ready.ts'), []); + assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []); }); test('R76 ignores test-shaped and non-daemon importers', () => { diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts index 67cc9e1be5..f8da37b2dd 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -123,26 +123,6 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] 'sole place that names the Apple runner owner, the Android snapshot-helper and Web ' + 'orphan cleanups, and legacy app-log marker recovery.', }, - { - file: 'src/daemon/device/device-claim-owner-recovery.ts', - target: 'src/platform-runtime.ts', - symbols: ['createPlatformRuntimeGateway'], - classification: 'composition-essential', - rationale: - "per-transaction neutral gateway assembly scoped to the dead owner's state dir " + - '(#2168); the process root cannot carry a per-claim sessionsDir, so the scoped ' + - "composition belongs to the recovery policy's own module.", - }, - { - file: 'src/daemon/device/device-ready.ts', - target: 'src/platform-runtime-device-ready.ts', - symbols: ['ensureLocalPlatformDeviceReady'], - classification: 'composition-essential', - rationale: - 'neutral local-device-readiness port assembled at the root composition layer (the ' + - 'platform dispatch is internal to the root module); the daemon keeps its TTL cache ' + - 'and provider-device policy locally.', - }, { file: 'src/daemon/direct-ios-selector.ts', target: 'src/platform-runtime-apple-resources.ts', @@ -154,54 +134,6 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] 'audit deliberately adds no second selector producer.', deepenedBy: '#2273, #2274', }, - { - file: 'src/daemon/ios-app-session-hint.ts', - target: 'src/platform-runtime-apple-resources.ts', - symbols: ['appleSessionObservation'], - classification: 'daemon-policy-essential', - rationale: - 'daemon-owned hint composition and length limits consume the neutral foreground-app ' + - 'observation; the Apple package owns ambiguity and probe mechanics.', - }, - { - file: 'src/daemon/request-recording-health.ts', - target: 'src/platform-runtime-apple-resources.ts', - symbols: ['appleSessionObservation'], - classification: 'daemon-policy-essential', - rationale: - 'daemon-owned recording invalidation consumes only liveness and session identity ' + - 'through the neutral observation contract; runner mechanics stay Apple-owned.', - }, - { - file: 'src/daemon/session-device-resolution.ts', - target: 'src/platform-runtime-apple-resources.ts', - symbols: ['appleSessionObservation'], - classification: 'daemon-policy-essential', - rationale: - 'daemon-owned device refresh uses the neutral runner-session observation as boot ' + - 'evidence; inventory selection and provider exclusions remain local policy.', - }, - { - file: 'src/daemon/session-lifecycle/internal/session-open-execution.ts', - target: 'src/platform-runtime-device-boot.ts', - symbols: ['deviceBootObservation'], - classification: 'daemon-policy-essential', - rationale: - 'daemon-owned claim reconciliation asks the device when it last booted to decide whether a ' + - 'foreign claim can still describe live ownership; the per-family probe mechanics stay in the ' + - 'Apple and Android packages behind the neutral observation contract (#2538).', - }, - { - file: 'src/daemon/handlers/session-selector-dispatch.ts', - target: 'src/platform-runtime-open-target.ts', - symbols: ['resolveSessionAppBundleIdForTarget'], - classification: 'daemon-policy-essential', - rationale: - 'selector dispatch reconstructs the session app-bundle identity after a trigger-app-event ' + - 'deep link through the one neutral open-plan resolver (#2334); Android package resolution ' + - 'moved behind the Android owning seam in packages/platform-android, so the resolver is the ' + - 'only symbol this edge names.', - }, { file: 'src/daemon/server/daemon-runtime.ts', target: 'src/core/interactors.ts', @@ -236,15 +168,25 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] 'src/daemon/provider-device-admission.ts, so this edge is the composition and nothing else.', }, { - file: 'src/daemon/session-lifecycle/internal/session-open-prepare.ts', - target: 'src/platform-runtime-open-target.ts', - symbols: ['resolveRequestedOpenSurface', 'validateOpenRelaunchTarget'], - classification: 'daemon-policy-essential', + file: 'src/daemon/server/daemon-runtime.ts', + target: 'src/platform-runtime-daemon-services.ts', + symbols: ['createDaemonPlatformServices'], + classification: 'composition-essential', + rationale: + 'process-root assembly of the daemon platform-services port (ADR 0019): local readiness, ' + + 'boot and runner-session observation, and open-target classification reach the request ' + + 'path only as this frozen object, so no daemon module names a platform adapter. Device ' + + 'execution never enters the port; it stays behind the request-bound runtime binding.', + }, + { + file: 'src/daemon/server/daemon-runtime.ts', + target: 'src/platform-runtime-claim-recovery.ts', + symbols: ['createClaimRecoveryGateway'], + classification: 'composition-essential', rationale: - 'open-prepare policy consumes only the neutral open plan/result surface (#2334): surface ' + - 'classification and relaunch-target validation. The platform mechanics that used to share ' + - 'the file (Android package resolution) moved behind the Android owning seam, leaving this ' + - 'edge daemon policy over two neutral, non-mechanics functions.', + 'process-root assembly of the per-transaction recovery gateway factory (#2168): the daemon ' + + 'owns which dead owner state dir a recovery is rebuilt from and when the gateway is ' + + 'disposed, and this composition module is the sole place that assembles the gateway itself.', }, ] as const; diff --git a/scripts/layering/root-module-zones.ts b/scripts/layering/root-module-zones.ts index 1821281106..2783280554 100644 --- a/scripts/layering/root-module-zones.ts +++ b/scripts/layering/root-module-zones.ts @@ -50,8 +50,10 @@ export const ROOT_MODULE_ZONES: Readonly> = { 'src/platform-runtime-android-observation-host.ts', 'src/platform-runtime-apple-resources.ts', 'src/platform-runtime-apple-runner-owner.ts', + 'src/platform-runtime-claim-recovery.ts', 'src/platform-runtime-daemon-lifecycle.ts', 'src/platform-runtime-daemon-owner-cleanup.ts', + 'src/platform-runtime-daemon-services.ts', 'src/platform-runtime-device-boot.ts', 'src/platform-runtime-device-inventory.ts', 'src/platform-runtime-device-ready.ts',