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..e58b87f4de 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.test.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.test.ts @@ -8,10 +8,10 @@ import { isRootPlatformRuntimeTarget, } from './daemon-platform-runtime-inventory.ts'; -// The classified pair these fixtures plant is the platform-services composition factory: the +// 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-daemon-services.ts'; -const CLASSIFIED_STUB = 'export function createDaemonPlatformServices() { return {}; }\n'; +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[] { @@ -26,8 +26,8 @@ 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', + "import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" + + 'void createClaimRecoveryGateway;\n', }; assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []); }); @@ -54,8 +54,8 @@ test('R76 reports every classified edge missing from the tree as stale, not the const sources = { [CLASSIFIED_TARGET]: CLASSIFIED_STUB, [CLASSIFIED_IMPORTER]: - "import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" + - 'void createDaemonPlatformServices;\n', + "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', @@ -86,32 +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 = { - [CLASSIFIED_TARGET]: CLASSIFIED_STUB + 'export function extraServicesKnob() {}\n', + [CLASSIFIED_TARGET]: CLASSIFIED_STUB + 'export function extraGatewayKnob() {}\n', [CLASSIFIED_IMPORTER]: - "import { createDaemonPlatformServices, extraServicesKnob } from '../../platform-runtime-daemon-services.ts';\n" + - 'void [createDaemonPlatformServices, extraServicesKnob];\n', + "import { createClaimRecoveryGateway, extraGatewayKnob } from '../../platform-runtime-claim-recovery.ts';\n" + + 'void [createClaimRecoveryGateway, extraGatewayKnob];\n', }; 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, /createDaemonPlatformServices, extraServicesKnob/); + assert.match(found[0]!.message, /createClaimRecoveryGateway, extraGatewayKnob/); }); -test('R76 rejects a services object widened through an expanded composition edge', () => { +test('R76 rejects a gateway assembled from an expanded recovery edge', () => { const sources = { - 'src/platform-runtime-daemon-services.ts': - 'export function createDaemonPlatformServices() { return {}; }\n' + - 'export function createProviderPlatformServices() { return {}; }\n', + 'src/platform-runtime-claim-recovery.ts': + 'export const createClaimRecoveryGateway = () => ({});\n' + + 'export const createProviderRecoveryGateway = () => ({});\n', 'src/daemon/server/daemon-runtime.ts': - "import {\n createDaemonPlatformServices,\n createProviderPlatformServices,\n} from '../../platform-runtime-daemon-services.ts';\n" + - 'void [createDaemonPlatformServices, createProviderPlatformServices];\n', + "import {\n createClaimRecoveryGateway,\n createProviderRecoveryGateway,\n} from '../../platform-runtime-claim-recovery.ts';\n" + + 'void [createClaimRecoveryGateway, createProviderRecoveryGateway];\n', }; 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, /createDaemonPlatformServices, createProviderPlatformServices/); + assert.match(found[0]!.message, /createClaimRecoveryGateway, createProviderRecoveryGateway/); }); test('R76 matches a destructured dynamic import by target with the recorded bindings', () => { @@ -210,9 +210,9 @@ 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', + "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, CLASSIFIED_IMPORTER), []); }); diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts index acc8a3b8e0..f8da37b2dd 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -123,16 +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/direct-ios-selector.ts', target: 'src/platform-runtime-apple-resources.ts', @@ -188,6 +178,16 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] '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: + '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; function keyOf(file: string, target: string): string { diff --git a/scripts/layering/root-module-zones.ts b/scripts/layering/root-module-zones.ts index 72d3a90c2d..2783280554 100644 --- a/scripts/layering/root-module-zones.ts +++ b/scripts/layering/root-module-zones.ts @@ -50,6 +50,7 @@ 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', 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({