Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/adr/0021-host-simlock-managed-device-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,14 @@ reuses the command's request envelope and reserves the canonical teardown budget
finalization. Unbounded commands require a bounded child request before managed execution.
An `admitted` result reports only allocator-confirmed authority; `teardown-required` leaves that
binding permanently fenced. Budget reservation is not proof of cleanup or runner quiescence.
The neutral service enables no managed runtime or readiness path. Integration follows the reviewed
managed-operation projection and must use canonical teardown before returning the allocation.
Request runtime binding accepts a matching lease service and command horizon from its coordinator.
Exact managed binding admits the allocator-held claim and confirms that horizon before native bind
probes; readiness activates only after the binding is adopted and its requested operations are
admitted. The managed runtime owner dispatches each reviewed operation inside lease admission.
Request disposal cancels pending admissions and revokes readiness before cleanup begins, while
shared renewal and late-binding cleanup retain their existing owners. Unconfigured managed requests
remain refused. This seam does not provide a publication/recovery coordinator, which must use
canonical teardown before returning the allocation.

Release is durable and retryable. Host does not publish a replacement grant while Simlock may still
mutate the device. After either daemon restarts, the journal is reconciled through Simlock lookup: a
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
LeaseRequestStatus,
ManagedLease,
ManagedLeasePlatform,
} from '@agent-device/contracts/managed-device-allocation';
import { Deadline } from '@agent-device/host-kit/retry';
import { createManagedLeaseReachability } from '../../../managed-device-reachability.ts';
Expand Down Expand Up @@ -35,12 +36,16 @@ export function setupAdmission(
grant?: LeaseRequestStatus;
script?: NonNullable<Parameters<typeof createScriptedManagedDeviceAllocator>[0]>['script'];
safetyWindowMs?: number;
platform?: ManagedLeasePlatform;
} = {},
) {
const grant = options.grant ?? granted({ lease: renewedLease({ ttlDeadline: NOW + 5_000 }) });
if (!grant.lease) throw new Error('Fixture needs a lease');
const allocator = createScriptedManagedDeviceAllocator({ script: options.script });
const reachability = createManagedLeaseReachability({ platform: 'ios', lease: grant.lease });
const reachability = createManagedLeaseReachability({
platform: options.platform ?? 'ios',
lease: grant.lease,
});
const admission = createManagedLeaseAdmission({
allocator,
grant,
Expand Down
86 changes: 86 additions & 0 deletions src/daemon/managed-device-allocation/request-admission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors';
import { deviceIdentity, deviceIdentityKey, type DeviceInfo } from '@agent-device/kernel/device';
import {
sameRuntimeOwner,
type DeviceBindingIntent,
} from '@agent-device/contracts/platform-runtime';
import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host';
import type { ManagedCommandHorizon, ManagedLeaseAdmission } from './lease-admission.ts';

export type ManagedRequestLease = Readonly<{
lease: ManagedLeaseAdmission;
horizon: ManagedCommandHorizon;
}>;
export type ResolveManagedRequestLease = (
device: DeviceInfo,
intent: Extract<DeviceBindingIntent, { kind: 'exact-owner' }>,
) => ManagedRequestLease | undefined;

export type ManagedRequestAdmission = ReturnType<typeof createManagedRequestAdmission>;

export function createManagedRequestAdmission(params: {
device: DeviceInfo;
intent: Extract<DeviceBindingIntent, { kind: 'exact-owner' }>;
scope: PlatformRequestScope;
lifetime: AbortSignal;
resolve?: ResolveManagedRequestLease;
}) {
const configured = params.resolve?.(params.device, params.intent);
if (
!configured ||
!sameRuntimeOwner(configured.lease.owner, params.intent.owner) ||
configured.lease.fence.token !== params.intent.fence.token ||
configured.lease.fence.generation !== params.intent.fence.generation ||
deviceIdentityKey(deviceIdentity(configured.lease.reachability.device)) !==
deviceIdentityKey(deviceIdentity(params.device)) ||
configured.lease.reachability.device.simulatorSetPath !== params.device.simulatorSetPath
) {
throw new AppError(
'UNSUPPORTED_OPERATION',
'Managed binding has no matching lease admission.',
{
reason: 'managed-lease-admission-unavailable',
},
);
}
const { lease, horizon } = configured;
const signal = AbortSignal.any([params.scope.signal, params.lifetime]);
let active = false;
const runConfirmed = async <T>(task: () => Promise<T>): Promise<T> => {
const result = await lease.run(horizon, signal, task);
if (result.status === 'admitted') return result.value;
if (result.status === 'abandoned') throw createRequestCanceledError();
throw new AppError('COMMAND_FAILED', 'Managed lease does not authorize execution.', {
...result,
reason:
result.status === 'teardown-required'
? 'managed-lease-teardown-required'
: 'managed-command-deadline-exceeded',
retriable: false,
});
};
const admit = async <T>(task: () => Promise<T>): Promise<T> => {
if (!active)
throw new AppError('COMMAND_FAILED', 'Managed request is not admitted.', {
reason: 'managed-request-not-admitted',
});
return await runConfirmed(task);
};
const ensureReady = async () => await admit(async () => {});
const managedDevice = Object.freeze({
device: lease.reachability.device,
owner: lease.owner,
fence: lease.fence,
admit,
run: lease.reachability.run,
});
return {
scope: { ...params.scope, managedDevice },
bind: runConfirmed,
activate: () => {
if (signal.aborted) throw createRequestCanceledError();
active = true;
},
ensureReady,
};
}
63 changes: 43 additions & 20 deletions src/daemon/request-runtime-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import {
import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host';
import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations';
import { ensureDeviceReady, type DeviceReadyOptions } from './device-ready.ts';
import type {
ManagedRequestAdmission,
ResolveManagedRequestLease,
} from './managed-device-allocation/request-admission.ts';

const managedReadiness = new WeakMap<BoundDeviceIdentity, () => Promise<void>>();

export type BindDeviceRuntime = <
const Required extends readonly RuntimeOperationKey<PlatformRuntimeOperations>[],
Expand Down Expand Up @@ -75,20 +81,26 @@ export type BoundDeviceIdentity = Readonly<{
owner: RuntimeOwnerRef;
}>;

/** Runs legacy local readiness only after the request has crossed the binding/claim fence. */
/** Confirms managed authority or runs local readiness after binding and claim admission. */
export async function ensureBoundDeviceReady(
bound: BoundDeviceIdentity,
options: DeviceReadyOptions = {},
): Promise<void> {
switch (bound.owner.kind) {
case 'provider-runtime':
return;
case 'managed-local':
case 'managed-local': {
const ready = managedReadiness.get(bound);
if (ready) {
await ready();
return;
}
throw new AppError(
'UNSUPPORTED_OPERATION',
'Managed-device readiness is unavailable until allocator confirmation.',
{ reason: 'managed-readiness-unavailable' },
);
}
case 'local-family':
await ensureDeviceReady(bound.device, options);
}
Expand All @@ -101,29 +113,19 @@ export type RequestRuntimeBindings = AsyncDisposable &
bindExactDevice: BindExactDeviceRuntime;
}>;

/**
* Private broad-binding cache; handlers receive only the selected projection.
*
* `admitDeviceClaim` is the #1320 claim gate, and it runs as part of creating a
* binding, so the per-device cache below is also what makes it run once per
* device. Binding performs no device mutation — it composes the operation
* catalog — so a binding that has not been admitted is the last state before any
* device operation exists, and admitting here covers every handler by
* construction. A refusal rejects the cached promise, so a second `bindDevice`
* for the same device re-attempts rather than inheriting a rejected binding.
* The gate receives the very intent the gateway bound, so an exact-owner fence
* reaches claim admission unchanged.
*/
/** Owns request runtime bindings while exposing only the requested operation projection. */
export function createRequestRuntimeBindings(params: {
gateway: DeviceRuntimeGateway<PlatformRuntimeOperations>;
scope: PlatformRequestScope;
resolveManagedLease?: ResolveManagedRequestLease;
admitDeviceClaim: (
device: DeviceInfo,
owner: RuntimeOwnerRef,
intent: DeviceBindingIntent,
) => Promise<void>;
}): RequestRuntimeBindings {
const cleanups = new AsyncCleanupStack();
const managedLifetime = new AbortController();
const bindings = new Map<string, Promise<DeviceBinding<PlatformRuntimeOperations>>>();

const admitBinding = async (
Expand Down Expand Up @@ -151,19 +153,40 @@ export function createRequestRuntimeBindings(params: {
return narrowDeviceBinding(await bindingPromise, use);
};

// Exact-owner bindings deliberately bypass the cache, so they admit their own.
const bindExactDevice: BindExactDeviceRuntime = async (device, owner, fence, use, scope) => {
const intent: DeviceBindingIntent = { kind: 'exact-owner', owner, fence };
const published = await params.gateway.bind({ device, intent, scope });
const binding = await admitBinding(await adoptExactBinding(cleanups, published, scope), intent);
return narrowDeviceBinding(binding, use);
let managed: ManagedRequestAdmission | undefined;
if (owner.kind === 'managed-local') {
const { createManagedRequestAdmission } =
await import('./managed-device-allocation/request-admission.ts');
managed = createManagedRequestAdmission({
device,
intent,
scope,
lifetime: managedLifetime.signal,
resolve: params.resolveManagedLease,
});
}
if (managed) await params.admitDeviceClaim(device, owner, intent);
const published = managed
? await managed.bind(() => params.gateway.bind({ device, intent, scope: managed.scope }))
: await params.gateway.bind({ device, intent, scope });
const adopted = await adoptExactBinding(cleanups, published, scope);
const binding = managed ? adopted : await admitBinding(adopted, intent);
const bound = narrowDeviceBinding(binding, use);
managed?.activate();
if (managed) managedReadiness.set(bound, managed.ensureReady);
return bound;
};

return {
inspectFacts: async (device) => await params.gateway.inspectFacts(device),
bindDevice,
bindExactDevice,
[Symbol.asyncDispose]: async () => await cleanups[Symbol.asyncDispose](),
[Symbol.asyncDispose]: async () => {
managedLifetime.abort();
await cleanups[Symbol.asyncDispose]();
},
};
}

Expand Down
Loading
Loading