Skip to content
Merged
10 changes: 10 additions & 0 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ paths.
The proxy process is expected to be long-lived and self-serve. Recovery from a
stale or expired device lease should not require restarting the proxy.

A caller that owns a lease's lifetime, allocating it and releasing it itself,
can allocate with `retainOnClose`. Session `close` then leaves that lease and
its provider device in place, and only `leases.release`, expiry, or daemon
shutdown ends it. The default is unchanged so the CLI's proxy sharing still
frees devices on `close`. The allocated lease reports `retainOnClose: true`
only when the daemon honored it, and only the lease's own client can turn it
on for a lease the run already holds. An unexpired lease keeps an idle daemon
alive; one the caller stops heartbeating expires after its `ttlMs`, and idle
reap then shuts the daemon down within one more idle window.

## Consequences

Device contention can fail before platform execution with an explicit
Expand Down
22 changes: 22 additions & 0 deletions packages/contracts/src/__tests__/lease-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isInactiveLeaseError,
leaseScopeFromOptions,
leaseScopeFromRequest,
leaseScopeToAllocateRequest,
leaseScopeToCommandFlags,
leaseScopeToConnectionMetadata,
leaseScopeToLeaseRpcParams,
Expand Down Expand Up @@ -55,6 +56,27 @@ test('leaseScopeFromOptions normalizes public aliases and projects request meta'
});
});

test('retainOnClose travels from options to request meta, allocate request and rpc params', () => {
const scope = leaseScopeFromOptions({ tenant: 'tenant-a', runId: 'run-1', retainOnClose: true });

assert.equal(scope.leaseRetainOnClose, true);
assert.equal(leaseScopeToRequestMeta(scope)?.leaseRetainOnClose, true);
assert.equal(leaseScopeToAllocateRequest(scope).retainOnClose, true);
assert.equal(
leaseScopeToLeaseRpcParams(scope, 'lease_allocate', { includeTokenParam: false }).retainOnClose,
true,
);
assert.equal(
'retainOnClose' in
leaseScopeToLeaseRpcParams(scope, 'lease_release', { includeTokenParam: false }),
false,
);
assert.equal(
'leaseRetainOnClose' in leaseScopeFromOptions({ tenant: 'tenant-a', runId: 'run-1' }),
false,
);
});

test('leaseScopeFromRequest prefers metadata and falls back to legacy flags', () => {
assert.deepEqual(
leaseScopeFromRequest({
Expand Down
8 changes: 8 additions & 0 deletions packages/contracts/src/client-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type Lease = {
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
/** Present when the daemon keeps this lease through session `close`; an older daemon omits it. */
retainOnClose?: true;
createdAt?: number;
heartbeatAt?: number;
expiresAt?: number;
Expand All @@ -32,6 +34,12 @@ export type LeaseAllocateOptions = LeaseOptions & {
provider?: string;
deviceKey?: string;
clientId?: string;
/**
* Keeps the lease through session `close`; it then ends only through `leases.release`, expiry, or
* daemon shutdown. Asking for it on a lease the same client already holds for the run turns it on
* for that lease; the returned lease's `retainOnClose` says whether the daemon honored it.
*/
retainOnClose?: boolean;
};

export type LeaseScopedOptions = LeaseOptions & {
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/device-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type DeviceLease = {
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
retainOnClose?: true;
createdAt: number;
heartbeatAt: number;
expiresAt: number;
Expand Down
11 changes: 10 additions & 1 deletion packages/contracts/src/lease-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ export type LeaseScope = {
runId?: string;
leaseId?: string;
leaseTtlMs?: number;
leaseRetainOnClose?: boolean;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
};

export type LeaseDiagnosticsContext = Omit<LeaseScope, 'leaseTtlMs'>;
export type LeaseDiagnosticsContext = Omit<LeaseScope, 'leaseTtlMs' | 'leaseRetainOnClose'>;

export type LeaseRpcCommand = 'lease_allocate' | 'lease_heartbeat' | 'lease_release';

Expand All @@ -36,6 +37,7 @@ export type LeaseAllocateRequestScope = {
deviceKey?: string;
clientId?: string;
ttlMs?: number;
retainOnClose?: boolean;
};

export type LeaseScopedRequestScope = {
Expand All @@ -56,6 +58,7 @@ type LeaseRequestLike = {
runId?: string;
leaseId?: string;
leaseTtlMs?: number;
leaseRetainOnClose?: boolean;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
Expand All @@ -69,6 +72,7 @@ type LeaseOptionsLike = {
leaseId?: string;
leaseTtlMs?: number;
ttlMs?: number;
retainOnClose?: boolean;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
provider?: string;
Expand All @@ -82,6 +86,7 @@ export function leaseScopeFromRequest(req: LeaseRequestLike): LeaseScope {
runId: req.meta?.runId ?? readFlagString(req.flags, 'runId'),
leaseId: req.meta?.leaseId ?? readFlagString(req.flags, 'leaseId'),
leaseTtlMs: req.meta?.leaseTtlMs,
leaseRetainOnClose: req.meta?.leaseRetainOnClose,
leaseBackend: req.meta?.leaseBackend,
leaseProvider:
req.meta?.leaseProvider ??
Expand All @@ -98,6 +103,7 @@ export function leaseScopeFromOptions(options: LeaseOptionsLike): LeaseScope {
runId: options.runId,
leaseId: options.leaseId,
leaseTtlMs: options.leaseTtlMs ?? options.ttlMs,
leaseRetainOnClose: options.retainOnClose,
leaseBackend: options.leaseBackend,
leaseProvider: options.leaseProvider ?? options.provider,
deviceKey: options.deviceKey,
Expand All @@ -111,6 +117,7 @@ export function leaseScopeToRequestMeta(scope: LeaseScope): LeaseRequestLike['me
runId: scope.runId,
leaseId: scope.leaseId,
leaseTtlMs: scope.leaseTtlMs,
leaseRetainOnClose: scope.leaseRetainOnClose,
leaseBackend: scope.leaseBackend,
leaseProvider: scope.leaseProvider,
deviceKey: scope.deviceKey,
Expand Down Expand Up @@ -139,6 +146,7 @@ export function leaseScopeToAllocateRequest(scope: LeaseScope): LeaseAllocateReq
deviceKey: scope.deviceKey,
clientId: scope.clientId,
ttlMs: scope.leaseTtlMs,
retainOnClose: scope.leaseRetainOnClose,
}) as LeaseAllocateRequestScope;
}

Expand Down Expand Up @@ -177,6 +185,7 @@ export function leaseScopeToLeaseRpcParams(
...common,
...stripUndefined({
ttlMs: scope.leaseTtlMs,
retainOnClose: scope.leaseRetainOnClose,
backend: scope.leaseBackend,
}),
};
Expand Down
1 change: 1 addition & 0 deletions packages/kernel/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export type DaemonRequestMeta = {
runId?: string;
leaseId?: string;
leaseTtlMs?: number;
leaseRetainOnClose?: boolean;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
Expand Down
24 changes: 24 additions & 0 deletions src/client/lease-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,27 @@ test('lease client rejects invalid control responses and preserves normalized er
error.details?.reason === 'LEASE_SCOPE_MISMATCH',
);
});

test('allocate reports whether the daemon kept retainOnClose on the lease', async () => {
const requests: Array<Omit<DaemonRequest, 'token'>> = [];
const lease = {
leaseId: 'lease-1',
tenantId: 'tenant-a',
runId: 'run-a',
backend: 'ios-instance',
};
const allocate = async (honored: boolean) =>
await createAgentDeviceClient(
{},
{
transport: async (request) => {
requests.push(request);
return { ok: true, data: { lease: honored ? { ...lease, retainOnClose: true } : lease } };
},
},
).leases.allocate({ tenant: 'tenant-a', runId: 'run-a', retainOnClose: true });

assert.equal((await allocate(true)).retainOnClose, true);
assert.equal((await allocate(false)).retainOnClose, undefined);
assert.equal(requests[0]?.meta?.leaseRetainOnClose, true);
});
1 change: 1 addition & 0 deletions src/client/lease-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ function normalizeLease(data: Record<string, unknown>): Lease {
leaseProvider: readOptionalString(rawLease, 'leaseProvider'),
clientId: readOptionalString(rawLease, 'clientId'),
deviceKey: readOptionalString(rawLease, 'deviceKey'),
...(rawLease.retainOnClose === true ? { retainOnClose: true } : {}),
createdAt: typeof rawLease.createdAt === 'number' ? rawLease.createdAt : undefined,
heartbeatAt: typeof rawLease.heartbeatAt === 'number' ? rawLease.heartbeatAt : undefined,
expiresAt: typeof rawLease.expiresAt === 'number' ? rawLease.expiresAt : undefined,
Expand Down
4 changes: 0 additions & 4 deletions src/daemon/__tests__/daemon-runtime-app-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,23 +129,19 @@ test('daemon shutdown settles fenced app-log cleanup before finalization can rel
);
fs.mkdirSync(sessionStore.resolveSessionDir(session.name), { recursive: true });
fs.writeFileSync(resourcePath, `${JSON.stringify(envelope)}\n`);
const beforeDelete = vi.fn(async () => {});

const teardown = teardownDaemonSessionForShutdown({
ref: sessionStore.lookup(session.name)!,
sessionStore,
stderr: { write: () => {} },
beforeDelete,
});
await cleanupStarted;

expect(beforeDelete).not.toHaveBeenCalled();
expect(sessionStore.get(session.name)).toBeDefined();
releaseCleanup();
await teardown;

expect(forceCleanup).toHaveBeenCalledOnce();
expect(beforeDelete).toHaveBeenCalledOnce();
expect(sessionStore.get(session.name)).toBeUndefined();
expect(appLogResourceStore.read(resourcePath)).toMatchObject({
status: 'decoded',
Expand Down
33 changes: 33 additions & 0 deletions src/daemon/__tests__/http-server-rpc-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,39 @@ test('local command RPC keeps host paths unrestricted', async (t) => {
}
});

test('lease.allocate forwards retainOnClose to the handler as lease meta', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) return;
const received: DaemonRequest[] = [];
const server = await createDaemonHttpServer({
handleRequest: async (request): Promise<DaemonResponse> => {
received.push(request);
return { ok: true, data: {} };
},
});
try {
const port = await listenOnLoopback(server);
for (const retainOnClose of [true, undefined]) {
const response = await fetch(`http://127.0.0.1:${port}/rpc`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'req-1',
method: 'agent_device.lease.allocate',
params: { tenantId: 'tenant-a', runId: 'run-1', retainOnClose },
}),
});
assert.equal(response.status, 200);
}
assert.deepEqual(
received.map((request) => request.meta?.leaseRetainOnClose),
[true, undefined],
);
} finally {
await closeLoopbackServer(server);
}
});

test('only local command RPC keeps the client developer dir', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) return;
const root = mkdtempForTestSync('agent-device-http-developer-dir-');
Expand Down
41 changes: 41 additions & 0 deletions src/daemon/__tests__/lease-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect, vi } from 'vitest';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import { LeaseRegistry } from '../lease-registry.ts';
Expand All @@ -11,6 +12,11 @@ import {
} from '../lease-lifecycle.ts';
import type { DaemonRequest } from '../daemon-request.ts';

vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => {
const actual = await importOriginal<typeof import('@agent-device/host-kit/diagnostics')>();
return { ...actual, emitDiagnostic: vi.fn() };
});

test('admitRequestLeaseForLockedScope heartbeats and stores admitted lease on the request', () => {
let now = 1_000;
const sessionStore = makeSessionStore('agent-device-lease-lifecycle-');
Expand Down Expand Up @@ -124,6 +130,41 @@ test('releaseSessionLease releases with the stored session owner scope', async (
expect(provider).toEqual({ provider: 'proxy' });
});

test('releaseSessionLease leaves a retainOnClose lease and its provider device alone', async () => {
const leaseRegistry = new LeaseRegistry();
const lease = leaseRegistry.allocateLease({
tenantId: 'tenant-a',
runId: 'run-1',
clientId: 'client-a',
retainOnClose: true,
});
const session = makeIosSession('default', {
lease: {
leaseId: lease.leaseId,
tenantId: lease.tenantId,
runId: lease.runId,
leaseBackend: lease.backend,
clientId: lease.clientId,
},
});
const release = vi.fn(async () => ({ released: true }));

const provider = await releaseSessionLease({
session,
leaseRegistry,
leaseLifecycleProvider: { release },
});

expect(provider).toBeUndefined();
expect(release).not.toHaveBeenCalled();
expect(leaseRegistry.listActiveLeases()).toHaveLength(1);
expect(vi.mocked(emitDiagnostic)).toHaveBeenCalledWith({
level: 'info',
phase: 'session_lease_released',
data: { session: 'default', leaseId: lease.leaseId, released: false, retained: true },
});
});

test('releaseSessionLease retains provider session ownership for artifact lookup', async () => {
const leaseRegistry = new LeaseRegistry();
const lease = leaseRegistry.allocateLease({
Expand Down
27 changes: 27 additions & 0 deletions src/daemon/__tests__/lease-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ test('allocateLease is idempotent per tenant/run/backend and refreshes expiry',
assert.equal(second.expiresAt, 12_000);
});

test('a later allocation asking for retainOnClose turns it on for the reused lease', () => {
const registry = new LeaseRegistry();
const first = registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' });
const second = registry.allocateLease({
tenantId: 'tenant-a',
runId: 'run-1',
retainOnClose: true,
});
const third = registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' });
assert.equal(second.leaseId, first.leaseId);
assert.equal(first.retainOnClose, undefined);
assert.equal(second.retainOnClose, true);
assert.equal(third.retainOnClose, true);
});

test('a request without the owning clientId cannot turn retainOnClose on for a run lease', () => {
const registry = new LeaseRegistry();
const owned = registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1', clientId: 'a' });
const reused = registry.allocateLease({
tenantId: 'tenant-a',
runId: 'run-1',
retainOnClose: true,
});
assert.equal(reused.leaseId, owned.leaseId);
assert.equal(reused.retainOnClose, undefined);
});

test('heartbeatLease extends active lease and releaseLease is idempotent', () => {
let now = 1_000;
const registry = new LeaseRegistry({
Expand Down
Loading
Loading