Skip to content

Commit 0bebbe8

Browse files
authored
feat: admit managed requests within lease authority (#2319)
* feat(daemon): admit managed requests within lease authority * test: verify managed request authority and activation boundaries * test: run managed request admission in provider integration
1 parent ebdaa76 commit 0bebbe8

7 files changed

Lines changed: 592 additions & 24 deletions

File tree

docs/adr/0021-host-simlock-managed-device-allocation.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,14 @@ reuses the command's request envelope and reserves the canonical teardown budget
176176
finalization. Unbounded commands require a bounded child request before managed execution.
177177
An `admitted` result reports only allocator-confirmed authority; `teardown-required` leaves that
178178
binding permanently fenced. Budget reservation is not proof of cleanup or runner quiescence.
179-
The neutral service enables no managed runtime or readiness path. Integration follows the reviewed
180-
managed-operation projection and must use canonical teardown before returning the allocation.
179+
Request runtime binding accepts a matching lease service and command horizon from its coordinator.
180+
Exact managed binding admits the allocator-held claim and confirms that horizon before native bind
181+
probes; readiness activates only after the binding is adopted and its requested operations are
182+
admitted. The managed runtime owner dispatches each reviewed operation inside lease admission.
183+
Request disposal cancels pending admissions and revokes readiness before cleanup begins, while
184+
shared renewal and late-binding cleanup retain their existing owners. Unconfigured managed requests
185+
remain refused. This seam does not provide a publication/recovery coordinator, which must use
186+
canonical teardown before returning the allocation.
181187

182188
Release is durable and retryable. Host does not publish a replacement grant while Simlock may still
183189
mutate the device. After either daemon restarts, the journal is reconciled through Simlock lookup: a

src/daemon/managed-device-allocation/__tests__/lease-admission.fixtures.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
LeaseRequestStatus,
33
ManagedLease,
4+
ManagedLeasePlatform,
45
} from '@agent-device/contracts/managed-device-allocation';
56
import { Deadline } from '@agent-device/host-kit/retry';
67
import { createManagedLeaseReachability } from '../../../managed-device-reachability.ts';
@@ -35,12 +36,16 @@ export function setupAdmission(
3536
grant?: LeaseRequestStatus;
3637
script?: NonNullable<Parameters<typeof createScriptedManagedDeviceAllocator>[0]>['script'];
3738
safetyWindowMs?: number;
39+
platform?: ManagedLeasePlatform;
3840
} = {},
3941
) {
4042
const grant = options.grant ?? granted({ lease: renewedLease({ ttlDeadline: NOW + 5_000 }) });
4143
if (!grant.lease) throw new Error('Fixture needs a lease');
4244
const allocator = createScriptedManagedDeviceAllocator({ script: options.script });
43-
const reachability = createManagedLeaseReachability({ platform: 'ios', lease: grant.lease });
45+
const reachability = createManagedLeaseReachability({
46+
platform: options.platform ?? 'ios',
47+
lease: grant.lease,
48+
});
4449
const admission = createManagedLeaseAdmission({
4550
allocator,
4651
grant,
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors';
2+
import { deviceIdentity, deviceIdentityKey, type DeviceInfo } from '@agent-device/kernel/device';
3+
import {
4+
sameRuntimeOwner,
5+
type DeviceBindingIntent,
6+
} from '@agent-device/contracts/platform-runtime';
7+
import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host';
8+
import type { ManagedCommandHorizon, ManagedLeaseAdmission } from './lease-admission.ts';
9+
10+
export type ManagedRequestLease = Readonly<{
11+
lease: ManagedLeaseAdmission;
12+
horizon: ManagedCommandHorizon;
13+
}>;
14+
export type ResolveManagedRequestLease = (
15+
device: DeviceInfo,
16+
intent: Extract<DeviceBindingIntent, { kind: 'exact-owner' }>,
17+
) => ManagedRequestLease | undefined;
18+
19+
export type ManagedRequestAdmission = ReturnType<typeof createManagedRequestAdmission>;
20+
21+
export function createManagedRequestAdmission(params: {
22+
device: DeviceInfo;
23+
intent: Extract<DeviceBindingIntent, { kind: 'exact-owner' }>;
24+
scope: PlatformRequestScope;
25+
lifetime: AbortSignal;
26+
resolve?: ResolveManagedRequestLease;
27+
}) {
28+
const configured = params.resolve?.(params.device, params.intent);
29+
if (
30+
!configured ||
31+
!sameRuntimeOwner(configured.lease.owner, params.intent.owner) ||
32+
configured.lease.fence.token !== params.intent.fence.token ||
33+
configured.lease.fence.generation !== params.intent.fence.generation ||
34+
deviceIdentityKey(deviceIdentity(configured.lease.reachability.device)) !==
35+
deviceIdentityKey(deviceIdentity(params.device)) ||
36+
configured.lease.reachability.device.simulatorSetPath !== params.device.simulatorSetPath
37+
) {
38+
throw new AppError(
39+
'UNSUPPORTED_OPERATION',
40+
'Managed binding has no matching lease admission.',
41+
{
42+
reason: 'managed-lease-admission-unavailable',
43+
},
44+
);
45+
}
46+
const { lease, horizon } = configured;
47+
const signal = AbortSignal.any([params.scope.signal, params.lifetime]);
48+
let active = false;
49+
const runConfirmed = async <T>(task: () => Promise<T>): Promise<T> => {
50+
const result = await lease.run(horizon, signal, task);
51+
if (result.status === 'admitted') return result.value;
52+
if (result.status === 'abandoned') throw createRequestCanceledError();
53+
throw new AppError('COMMAND_FAILED', 'Managed lease does not authorize execution.', {
54+
...result,
55+
reason:
56+
result.status === 'teardown-required'
57+
? 'managed-lease-teardown-required'
58+
: 'managed-command-deadline-exceeded',
59+
retriable: false,
60+
});
61+
};
62+
const admit = async <T>(task: () => Promise<T>): Promise<T> => {
63+
if (!active)
64+
throw new AppError('COMMAND_FAILED', 'Managed request is not admitted.', {
65+
reason: 'managed-request-not-admitted',
66+
});
67+
return await runConfirmed(task);
68+
};
69+
const ensureReady = async () => await admit(async () => {});
70+
const managedDevice = Object.freeze({
71+
device: lease.reachability.device,
72+
owner: lease.owner,
73+
fence: lease.fence,
74+
admit,
75+
run: lease.reachability.run,
76+
});
77+
return {
78+
scope: { ...params.scope, managedDevice },
79+
bind: runConfirmed,
80+
activate: () => {
81+
if (signal.aborted) throw createRequestCanceledError();
82+
active = true;
83+
},
84+
ensureReady,
85+
};
86+
}

src/daemon/request-runtime-binding.ts

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import {
1616
import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host';
1717
import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations';
1818
import { ensureDeviceReady, type DeviceReadyOptions } from './device-ready.ts';
19+
import type {
20+
ManagedRequestAdmission,
21+
ResolveManagedRequestLease,
22+
} from './managed-device-allocation/request-admission.ts';
23+
24+
const managedReadiness = new WeakMap<BoundDeviceIdentity, () => Promise<void>>();
1925

2026
export type BindDeviceRuntime = <
2127
const Required extends readonly RuntimeOperationKey<PlatformRuntimeOperations>[],
@@ -75,20 +81,26 @@ export type BoundDeviceIdentity = Readonly<{
7581
owner: RuntimeOwnerRef;
7682
}>;
7783

78-
/** Runs legacy local readiness only after the request has crossed the binding/claim fence. */
84+
/** Confirms managed authority or runs local readiness after binding and claim admission. */
7985
export async function ensureBoundDeviceReady(
8086
bound: BoundDeviceIdentity,
8187
options: DeviceReadyOptions = {},
8288
): Promise<void> {
8389
switch (bound.owner.kind) {
8490
case 'provider-runtime':
8591
return;
86-
case 'managed-local':
92+
case 'managed-local': {
93+
const ready = managedReadiness.get(bound);
94+
if (ready) {
95+
await ready();
96+
return;
97+
}
8798
throw new AppError(
8899
'UNSUPPORTED_OPERATION',
89100
'Managed-device readiness is unavailable until allocator confirmation.',
90101
{ reason: 'managed-readiness-unavailable' },
91102
);
103+
}
92104
case 'local-family':
93105
await ensureDeviceReady(bound.device, options);
94106
}
@@ -101,29 +113,19 @@ export type RequestRuntimeBindings = AsyncDisposable &
101113
bindExactDevice: BindExactDeviceRuntime;
102114
}>;
103115

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

129131
const admitBinding = async (
@@ -151,19 +153,40 @@ export function createRequestRuntimeBindings(params: {
151153
return narrowDeviceBinding(await bindingPromise, use);
152154
};
153155

154-
// Exact-owner bindings deliberately bypass the cache, so they admit their own.
155156
const bindExactDevice: BindExactDeviceRuntime = async (device, owner, fence, use, scope) => {
156157
const intent: DeviceBindingIntent = { kind: 'exact-owner', owner, fence };
157-
const published = await params.gateway.bind({ device, intent, scope });
158-
const binding = await admitBinding(await adoptExactBinding(cleanups, published, scope), intent);
159-
return narrowDeviceBinding(binding, use);
158+
let managed: ManagedRequestAdmission | undefined;
159+
if (owner.kind === 'managed-local') {
160+
const { createManagedRequestAdmission } =
161+
await import('./managed-device-allocation/request-admission.ts');
162+
managed = createManagedRequestAdmission({
163+
device,
164+
intent,
165+
scope,
166+
lifetime: managedLifetime.signal,
167+
resolve: params.resolveManagedLease,
168+
});
169+
}
170+
if (managed) await params.admitDeviceClaim(device, owner, intent);
171+
const published = managed
172+
? await managed.bind(() => params.gateway.bind({ device, intent, scope: managed.scope }))
173+
: await params.gateway.bind({ device, intent, scope });
174+
const adopted = await adoptExactBinding(cleanups, published, scope);
175+
const binding = managed ? adopted : await admitBinding(adopted, intent);
176+
const bound = narrowDeviceBinding(binding, use);
177+
managed?.activate();
178+
if (managed) managedReadiness.set(bound, managed.ensureReady);
179+
return bound;
160180
};
161181

162182
return {
163183
inspectFacts: async (device) => await params.gateway.inspectFacts(device),
164184
bindDevice,
165185
bindExactDevice,
166-
[Symbol.asyncDispose]: async () => await cleanups[Symbol.asyncDispose](),
186+
[Symbol.asyncDispose]: async () => {
187+
managedLifetime.abort();
188+
await cleanups[Symbol.asyncDispose]();
189+
},
167190
};
168191
}
169192

0 commit comments

Comments
 (0)