Skip to content

Commit 6ec6471

Browse files
committed
feat(daemon): add fenced managed lease admission
1 parent 5c4b003 commit 6ec6471

7 files changed

Lines changed: 568 additions & 10 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,16 @@ per managed-device lease. A failed or uncertain renewal fences only the affected
169169
binding, reconciles through Simlock, and either publishes the confirmed deadline or tears down. No
170170
command continues on Host's cached deadline alone.
171171

172+
The daemon's lease-admission service is `createManagedLeaseAdmission` under
173+
`src/daemon/managed-device-allocation/`. One instance belongs to one binding incarnation;
174+
its coordinator must fence it before release, replacement, or supersession. `managedCommandHorizon`
175+
reuses the command's request envelope and reserves the canonical teardown budget including recording
176+
finalization. Unbounded commands require a bounded child request before managed execution.
177+
An `admitted` result reports only allocator-confirmed authority; `teardown-required` leaves that
178+
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.
181+
172182
Release is durable and retryable. Host does not publish a replacement grant while Simlock may still
173183
mutate the device. After either daemon restarts, the journal is reconciled through Simlock lookup: a
174184
live, authorized mapping reattaches; missing, terminal, stale, or unauthorized work follows

packages/contracts/src/platform-runtime-host.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DeviceInfo, Platform } from '@agent-device/kernel/device';
22
import type { JsonObject, JsonValue } from './json.ts';
33
import type { AndroidClipboardShellSupport } from './android-clipboard-support.ts';
4+
import type { ResourceOwnershipFence, RuntimeOwnerRef } from './platform-runtime.ts';
45

56
export type HostCommandRequest = Readonly<{
67
executable: string;
@@ -194,14 +195,11 @@ export type PlatformRequestScope = Readonly<{
194195
signal: AbortSignal;
195196
diagnostics: PlatformDiagnosticSink;
196197
progress: PlatformProgressSink;
197-
}>;
198-
199-
export type ResolvedNativeAsset = Readonly<{
200-
path: string;
201-
version?: string;
202-
}>;
203-
204-
/** Asset names stay package-owned literal unions rather than a shared universal catalog. */
205-
export type NativeAssetResolver<AssetName extends string> = Readonly<{
206-
resolve(name: AssetName, signal?: AbortSignal): Promise<ResolvedNativeAsset>;
198+
managedDevice?: Readonly<{
199+
device: DeviceInfo;
200+
owner: Extract<RuntimeOwnerRef, { kind: 'managed-local' }>;
201+
fence: ResourceOwnershipFence;
202+
ensureReady(): Promise<void>;
203+
run<T>(task: () => Promise<T>): Promise<T>;
204+
}>;
207205
}>;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { afterEach, expect, test, vi } from 'vitest';
2+
import { managedCommandHorizon } from '../command-horizon.ts';
3+
import { NOW } from './lease-admission.fixtures.ts';
4+
5+
afterEach(() => vi.restoreAllMocks());
6+
7+
test('command horizons reuse descriptor budgets and reject unbounded requests', () => {
8+
vi.spyOn(Date, 'now').mockReturnValue(NOW);
9+
const req = { command: 'wait', session: 'managed', positionals: ['text', 'Ready', '180000'] };
10+
const wait = managedCommandHorizon(req, NOW - 1_000);
11+
expect(wait.deadline.remainingMs()).toBe(209_000);
12+
expect(wait.teardownTimeoutMs).toBe(16_000);
13+
expect(
14+
managedCommandHorizon(
15+
{ command: 'install', session: 'managed', positionals: [] },
16+
NOW,
17+
).deadline.remainingMs(),
18+
).toBe(180_000);
19+
expect(() =>
20+
managedCommandHorizon({ command: 'test', session: 'managed', positionals: [] }, NOW),
21+
).toThrow(expect.objectContaining({ details: { reason: 'managed-command-deadline-unbounded' } }));
22+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type {
2+
LeaseRequestStatus,
3+
ManagedLease,
4+
} from '@agent-device/contracts/managed-device-allocation';
5+
import { Deadline } from '@agent-device/host-kit/retry';
6+
import { createManagedLeaseReachability } from '../../../managed-device-reachability.ts';
7+
import { createScriptedManagedDeviceAllocator } from '../../../__tests__/test-utils/managed-device-allocator.fixtures.ts';
8+
import { createManagedLeaseAdmission } from '../lease-admission.ts';
9+
import { ALLOCATION_GRANTED_STATUS, ALLOCATION_LEASE } from './fixtures.ts';
10+
11+
export const NOW = 1_700_000_000_000;
12+
export const controller = () => new AbortController();
13+
export const horizon = (milliseconds = 10_000) => ({
14+
deadline: Deadline.fromTimeoutMs(milliseconds),
15+
teardownTimeoutMs: 5_000,
16+
});
17+
export const renewedLease = (overrides: Partial<ManagedLease> = {}): ManagedLease => ({
18+
...ALLOCATION_LEASE,
19+
ttlDeadline: NOW + 100_000,
20+
...overrides,
21+
});
22+
export const granted = (overrides: Partial<LeaseRequestStatus> = {}): LeaseRequestStatus => ({
23+
...ALLOCATION_GRANTED_STATUS,
24+
lease: renewedLease(),
25+
...overrides,
26+
});
27+
export const unknownStatus: LeaseRequestStatus = {
28+
...ALLOCATION_GRANTED_STATUS,
29+
state: 'unknown',
30+
lease: undefined,
31+
};
32+
33+
export function setupAdmission(
34+
options: {
35+
grant?: LeaseRequestStatus;
36+
script?: NonNullable<Parameters<typeof createScriptedManagedDeviceAllocator>[0]>['script'];
37+
safetyWindowMs?: number;
38+
} = {},
39+
) {
40+
const grant = options.grant ?? granted({ lease: renewedLease({ ttlDeadline: NOW + 5_000 }) });
41+
if (!grant.lease) throw new Error('Fixture needs a lease');
42+
const allocator = createScriptedManagedDeviceAllocator({ script: options.script });
43+
const reachability = createManagedLeaseReachability({ platform: 'ios', lease: grant.lease });
44+
const admission = createManagedLeaseAdmission({
45+
allocator,
46+
grant,
47+
reachability,
48+
renewalSafetyWindowMs: options.safetyWindowMs ?? 5_000,
49+
});
50+
return { allocator, admission, grant, reachability };
51+
}
52+
53+
export function deferred<T>() {
54+
let resolve!: (value: T) => void;
55+
const promise = new Promise<T>((done) => {
56+
resolve = done;
57+
});
58+
return { promise, resolve };
59+
}
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { beforeEach, afterEach, expect, test, vi } from 'vitest';
2+
import type {
3+
LeaseRequestStatus,
4+
ManagedLease,
5+
} from '@agent-device/contracts/managed-device-allocation';
6+
import {
7+
NOW,
8+
controller,
9+
deferred,
10+
granted,
11+
horizon,
12+
renewedLease,
13+
setupAdmission,
14+
unknownStatus,
15+
} from './lease-admission.fixtures.ts';
16+
17+
beforeEach(() => {
18+
vi.useFakeTimers();
19+
vi.setSystemTime(NOW);
20+
});
21+
afterEach(() => vi.useRealTimers());
22+
23+
test('reuses only an allocator-confirmed horizon outside the safety window', async () => {
24+
const { admission, allocator } = setupAdmission({ grant: granted() });
25+
const task = vi.fn(async () => 'value');
26+
expect(await admission.run(horizon(), controller().signal, task)).toMatchObject({
27+
status: 'admitted',
28+
ttlDeadline: NOW + 100_000,
29+
value: 'value',
30+
});
31+
expect(allocator.calls).toEqual([]);
32+
const near = setupAdmission({
33+
grant: granted(),
34+
safetyWindowMs: 100_000,
35+
script: { renewLease: [renewedLease({ ttlDeadline: NOW + 200_000 })] },
36+
});
37+
expect((await near.admission.run(horizon(), controller().signal, task)).status).toBe('admitted');
38+
expect(near.allocator.calls).toHaveLength(1);
39+
});
40+
41+
test('same-lease waiters share renewal while a different lease proceeds independently', async () => {
42+
const pending = deferred<ManagedLease>();
43+
const first = setupAdmission({ script: { renewLease: [pending.promise] } });
44+
const task = vi.fn(async () => {});
45+
const a = first.admission.run(horizon(), controller().signal, task);
46+
const b = first.admission.run(horizon(), controller().signal, task);
47+
const other = setupAdmission({
48+
grant: granted({ lease: renewedLease({ id: 'lease-2', ttlDeadline: NOW + 1_000 }) }),
49+
script: { renewLease: [renewedLease({ id: 'lease-2' })] },
50+
});
51+
expect((await other.admission.run(horizon(), controller().signal, async () => {})).status).toBe(
52+
'admitted',
53+
);
54+
expect(first.allocator.calls).toHaveLength(1);
55+
expect(task).not.toHaveBeenCalled();
56+
pending.resolve(renewedLease());
57+
expect((await Promise.all([a, b])).map((result) => result.status)).toEqual([
58+
'admitted',
59+
'admitted',
60+
]);
61+
});
62+
63+
test('a longer-horizon waiter obtains its own confirmation after joining a shorter renewal', async () => {
64+
const short = deferred<ManagedLease>();
65+
const long = deferred<ManagedLease>();
66+
const { admission, allocator } = setupAdmission({
67+
script: { renewLease: [short.promise, long.promise] },
68+
});
69+
const longTask = vi.fn(async () => {});
70+
const a = admission.run(horizon(), controller().signal, async () => {});
71+
const b = admission.run(horizon(200_000), controller().signal, longTask);
72+
short.resolve(renewedLease());
73+
expect((await a).status).toBe('admitted');
74+
expect(longTask).not.toHaveBeenCalled();
75+
expect(allocator.calls[1]).toEqual({
76+
method: 'renewLease',
77+
input: { leaseId: 'lease-1', ttlDeadline: NOW + 210_000 },
78+
});
79+
long.resolve(renewedLease({ ttlDeadline: NOW + 205_000 }));
80+
expect((await b).status).toBe('admitted');
81+
});
82+
83+
test.each([
84+
{ ttlDeadline: NOW - 1 },
85+
{ ttlDeadline: NOW + 14_999 },
86+
{ ttlDeadline: Number.NaN },
87+
{ ttlDeadline: Number.POSITIVE_INFINITY },
88+
{ id: 'wrong' },
89+
{ device: { address: 'wrong' } },
90+
{ environment: { SIMLOCK_IOS_DEVICE_SET: '/foreign' } },
91+
])('invalid renewal %j never becomes local authority', async (overrides) => {
92+
const { admission, allocator } = setupAdmission({
93+
script: { renewLease: [renewedLease(overrides)], getLeaseRequestStatus: [unknownStatus] },
94+
});
95+
const task = vi.fn(async () => {});
96+
expect(await admission.run(horizon(), controller().signal, task)).toMatchObject({
97+
status: 'teardown-required',
98+
reason: 'authority-unconfirmed',
99+
});
100+
expect((await admission.run(horizon(), controller().signal, task)).status).toBe(
101+
'teardown-required',
102+
);
103+
expect(task).not.toHaveBeenCalled();
104+
expect(allocator.calls.map((call) => call.method)).toEqual([
105+
'renewLease',
106+
'getLeaseRequestStatus',
107+
]);
108+
});
109+
110+
test('a lost renewal response is reconciled through the exact durable attempt', async () => {
111+
const { admission, allocator } = setupAdmission({
112+
script: { renewLease: [new Error('lost response')], getLeaseRequestStatus: [granted()] },
113+
});
114+
expect(
115+
await admission.run(horizon(), controller().signal, async () => 'confirmed'),
116+
).toMatchObject({ status: 'admitted', ttlDeadline: NOW + 100_000 });
117+
expect(allocator.calls[1]).toEqual({
118+
method: 'getLeaseRequestStatus',
119+
input: { requesterId: 'requester-a', attemptKey: 'attempt-1' },
120+
});
121+
});
122+
123+
test.each([
124+
unknownStatus,
125+
granted({ state: 'cancelled' }),
126+
granted({ state: 'superseded' }),
127+
granted({ state: 'pending' }),
128+
granted({ state: 'refused' }),
129+
granted({ requesterId: 'other' }),
130+
granted({ attemptKey: 'other' }),
131+
granted({ requestGeneration: 2 }),
132+
granted({ identityIncarnationId: 'other' }),
133+
granted({ lease: renewedLease({ ttlDeadline: NOW + 14_999 }) }),
134+
new Error('lookup unavailable'),
135+
])('unconfirmed lookup %j requires canonical teardown', async (status) => {
136+
const { admission } = setupAdmission({
137+
script: { renewLease: [new Error('renewal failed')], getLeaseRequestStatus: [status] },
138+
});
139+
const task = vi.fn(async () => {});
140+
expect((await admission.run(horizon(), controller().signal, task)).status).toBe(
141+
'teardown-required',
142+
);
143+
expect(task).not.toHaveBeenCalled();
144+
});
145+
146+
test('abort abandons one caller and does not cancel renewal or another waiter', async () => {
147+
const pending = deferred<ManagedLease>();
148+
const { admission, allocator } = setupAdmission({ script: { renewLease: [pending.promise] } });
149+
const abort = controller();
150+
const task = vi.fn(async () => {});
151+
const a = admission.run(horizon(), abort.signal, task);
152+
const b = admission.run(horizon(), controller().signal, async () => {});
153+
abort.abort();
154+
expect(await a).toEqual({ status: 'abandoned' });
155+
pending.resolve(renewedLease());
156+
expect((await b).status).toBe('admitted');
157+
expect(task).not.toHaveBeenCalled();
158+
expect(allocator.calls).toHaveLength(1);
159+
});
160+
161+
test('caller deadline ends the wait while a late renewal can still confirm authority', async () => {
162+
const pending = deferred<ManagedLease>();
163+
const { admission, allocator } = setupAdmission({ script: { renewLease: [pending.promise] } });
164+
const a = admission.run(horizon(), controller().signal, async () => {
165+
throw new Error('expired operation');
166+
});
167+
await vi.advanceTimersByTimeAsync(10_000);
168+
expect(await a).toEqual({ status: 'deadline-exceeded' });
169+
pending.resolve(renewedLease());
170+
expect((await admission.run(horizon(), controller().signal, async () => {})).status).toBe(
171+
'admitted',
172+
);
173+
expect(allocator.calls).toHaveLength(1);
174+
});
175+
176+
test.each(['released', 'replaced', 'superseded', 'fenced'] as const)(
177+
'%s binding cannot be revived by a late renewal or lookup',
178+
async (reason) => {
179+
for (const recovering of [false, true]) {
180+
const pending = deferred<ManagedLease | LeaseRequestStatus>();
181+
const script = recovering
182+
? { renewLease: [new Error('lost')], getLeaseRequestStatus: [pending.promise] }
183+
: { renewLease: [pending.promise] };
184+
const { admission, allocator } = setupAdmission({ script });
185+
const task = vi.fn(async () => {});
186+
const a = admission.run(horizon(), controller().signal, task);
187+
if (recovering) await vi.waitFor(() => expect(allocator.calls).toHaveLength(2));
188+
admission.fenceBinding(reason);
189+
pending.resolve(recovering ? granted() : renewedLease());
190+
expect(await a).toMatchObject({ status: 'teardown-required', reason });
191+
expect((await admission.run(horizon(), controller().signal, task)).status).toBe(
192+
'teardown-required',
193+
);
194+
expect(task).not.toHaveBeenCalled();
195+
}
196+
},
197+
);
198+
199+
test('an already aborted caller starts no allocator work and an operation failure stays primary', async () => {
200+
const abort = controller();
201+
abort.abort();
202+
const { admission, allocator } = setupAdmission({ grant: granted() });
203+
expect(await admission.run(horizon(), abort.signal, async () => {})).toEqual({
204+
status: 'abandoned',
205+
});
206+
const failure = new Error('operation failed');
207+
await expect(
208+
admission.run(horizon(), controller().signal, async () => {
209+
throw failure;
210+
}),
211+
).rejects.toBe(failure);
212+
expect(allocator.calls).toEqual([]);
213+
});
214+
215+
test('fencing wakes a waiter even when durable renewal never responds', async () => {
216+
const { admission } = setupAdmission({
217+
script: { renewLease: [deferred<ManagedLease>().promise] },
218+
});
219+
const waiter = admission.run(horizon(), controller().signal, async () => {
220+
throw new Error('fenced operation');
221+
});
222+
admission.fenceBinding('released');
223+
expect(await waiter).toEqual({ status: 'teardown-required', reason: 'released' });
224+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Deadline } from '@agent-device/host-kit/retry';
2+
import { AppError } from '@agent-device/kernel/errors';
3+
import { resolveDaemonRequestTimeoutMs } from '../request-timeout.ts';
4+
import { resolveDaemonSessionTeardownTimeoutMs } from '../session-teardown-budget.ts';
5+
import type { DaemonRequest } from '../types.ts';
6+
import type { ManagedCommandHorizon } from './lease-admission.ts';
7+
8+
/** Reserve recording cleanup even when this command is the one that starts the capture. */
9+
export function managedCommandHorizon(
10+
req: Omit<DaemonRequest, 'token'>,
11+
startedAtMs: number,
12+
): ManagedCommandHorizon {
13+
const timeoutMs = resolveDaemonRequestTimeoutMs(req);
14+
if (
15+
timeoutMs === undefined ||
16+
!Number.isFinite(timeoutMs) ||
17+
timeoutMs <= 0 ||
18+
!Number.isFinite(startedAtMs)
19+
) {
20+
throw new AppError(
21+
'UNSUPPORTED_OPERATION',
22+
'Managed commands require a finite request deadline.',
23+
{
24+
reason: 'managed-command-deadline-unbounded',
25+
},
26+
);
27+
}
28+
return Object.freeze({
29+
deadline: Deadline.fromTimeoutMs(timeoutMs, startedAtMs),
30+
teardownTimeoutMs: resolveDaemonSessionTeardownTimeoutMs(undefined, true),
31+
});
32+
}

0 commit comments

Comments
 (0)