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
14 changes: 14 additions & 0 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ in-flight work and human-control holds still keep a lease from expiring. Once
daemon shutdown begins, no new lease-end teardown starts, and shutdown waits for
any that already started before it tears sessions down itself.

The daemon ends a session's lease when a request closes that session, unless the lease was
allocated with `retainOnClose`. The reply to that request reports each lease it ended once, at
response level, as `endedLeases: [{ leaseId, outcome: 'released' }]`: in `data` on success, in
`error.details` on failure, including a close that failed after the release. This holds for every
request that can close a session: `close`, a `batch` close step, a `replay` or `test` script's
`close`, and the session cleanup `test` runs after each script. Nested requests record on the
outermost request's ledger, so a step reply never repeats it. A close that finds its lease already
inactive reports it as ended. A client never sends a release after such a request. A client that
stores the lease id forgets it when the reply names it, and a gateway that relays the reply reads
the lease end from it. Close and lease-end teardown take the same execution locks, so whichever
runs second finds the session gone: a close after a lease end has no session and ends no lease, and
a client that still stores that lease id finds it inactive on its next heartbeat and allocates a
new lease under a fresh id.

## Consequences

Device contention can fail before platform execution with an explicit
Expand Down
9 changes: 9 additions & 0 deletions packages/contracts/src/client-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export type Lease = {
expiresAt?: number;
};

/**
* A session lease a daemon request ended. A reply to a request that ended leases lists them once in
* `endedLeases` at response level: in `data` on success, in `error.details` on failure. A `close`,
* a `batch` close step, a `replay` or `test` script's close, and `test` session cleanup each end the
* lease they close with, unless it was allocated with `retainOnClose`. A daemon older than this
* field omits it.
*/
export type EndedLease = { leaseId: string; outcome: 'released' };

export type LeaseOptions = AgentDeviceRequestOverrides &
AgentDeviceSelectionOptions & {
ttlMs?: number;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/facades/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type {
} from '../client-gesture.ts';
export type {
CloudArtifactsOptions,
EndedLease,
HumanControlHold,
HumanControlHoldOptions,
HumanControlHoldScope,
Expand Down
15 changes: 14 additions & 1 deletion packages/contracts/src/lease-scope.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { LeaseBackend } from '@agent-device/kernel/contracts';
import type { DaemonResponse, LeaseBackend } from '@agent-device/kernel/contracts';
import { stripUndefined } from '@agent-device/kernel/record';
import type { CloudProviderProfileFields } from './remote-config-fields.ts';
import type { CommandFlags } from './command-flags.ts';
import type { EndedLease } from './client-lease.ts';

const PROXY_LEASE_PROVIDER = 'proxy';

Expand Down Expand Up @@ -343,6 +344,18 @@ export function isInactiveLeaseError(error: unknown): boolean {
);
}

/** The leases a daemon reply says its request ended; a malformed entry is no ended lease. */
export function readEndedLeases(response: DaemonResponse): EndedLease[] {
const body = response.ok ? response.data : response.error.details;
const entries = body?.endedLeases;
if (!Array.isArray(entries)) return [];
return entries.flatMap((entry: unknown) => {
if (typeof entry !== 'object' || entry === null) return [];
const { leaseId, outcome } = entry as Readonly<{ leaseId?: unknown; outcome?: unknown }>;
return typeof leaseId === 'string' && outcome === 'released' ? [{ leaseId, outcome }] : [];
});
}

function leaseScopeToScopedRequest(scope: LeaseScope): LeaseScopedRequestScope {
return stripUndefined({
leaseId: scope.leaseId ?? '',
Expand Down
146 changes: 146 additions & 0 deletions packages/daemon/src/core/__tests__/request-ended-leases.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures';
import fs from 'node:fs';
import path from 'node:path';
import { expect, test } from 'vitest';
import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir';
import { replayScriptSourceBundleFor } from '@agent-device/replay-port/replay-script-source-fixtures';
import { makeSessionStore } from '../../__tests__/support/store-factory.ts';
import { makeIosSession } from '../../__tests__/support/session-factories.ts';
import {
createRequestHandler,
lifecycleDeviceRuntimeGateway,
} from '../../__tests__/support/test-device-runtime-gateway.ts';
import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts';
import { LeaseRegistry } from '../../resources/lease-registry.ts';
import type { DaemonRequest } from '../daemon-request.ts';
import {
type EndedLeaseLedger,
onDelegatingLedger,
recordEndedLease,
reportEndedLeases,
} from '../request-ended-leases.ts';

const SESSION = 'default';

function leasedDaemon() {
const root = mkdtempForTestSync('agent-device-ended-leases-');
const sessionStore = makeSessionStore('agent-device-ended-leases-store-');
const leaseRegistry = new LeaseRegistry();
const lease = leaseRegistry.allocateLease({
tenantId: 'tenant-a',
runId: 'run-1',
leaseProvider: 'proxy',
clientId: 'client-a',
deviceKey: 'ios:sim-1',
});
sessionStore.publish(
SESSION,
makeIosSession(SESSION, {
lease: {
leaseId: lease.leaseId,
tenantId: lease.tenantId,
runId: lease.runId,
leaseProvider: lease.leaseProvider,
clientId: lease.clientId,
deviceKey: lease.deviceKey,
},
}),
);
const handler = createRequestHandler({
createCommandSurface: fakeCreateCommandSurface,
logPath: path.join(root, 'daemon.log'),
token: 'test-token',
sessionStore,
leaseRegistry,
deviceInventoryGateways: createTestDeviceInventoryGateways(),
deviceRuntimeGateway: lifecycleDeviceRuntimeGateway,
trackDownloadableArtifact: () => 'artifact-id',
});
return {
root,
handler,
leaseRegistry,
endedLeases: [{ leaseId: lease.leaseId, outcome: 'released' }],
};
}

function request(command: string, positionals: string[] = [], flags = {}): DaemonRequest {
return { token: 'test-token', session: SESSION, command, positionals, flags };
}

test('a close reports the lease it ended once, at response level', async () => {
const { handler, leaseRegistry, endedLeases } = leasedDaemon();

const response = await handler(request('close'));

expect(response).toMatchObject({ ok: true, data: { endedLeases } });
expect(leaseRegistry.listActiveLeases()).toHaveLength(0);
});

test('a batch reports the lease its close step ended on the batch reply only', async () => {
const { handler, endedLeases } = leasedDaemon();

const response = await handler(
request('batch', [], { batchSteps: [{ command: 'close', positionals: [], flags: {} }] }),
);

expect(response).toMatchObject({
ok: true,
data: { endedLeases, results: [{ command: 'close', ok: true }] },
});
expect(response).not.toMatchObject({ data: { results: [{ data: { endedLeases } }] } });
});

test('a batch that fails after its close step reports the ended lease in the error details', async () => {
const { handler, endedLeases } = leasedDaemon();

const response = await handler(
request('batch', [], {
batchSteps: [
{ command: 'close', positionals: [], flags: {} },
{ command: 'snapshot', positionals: [], flags: {} },
],
}),
);

expect(response).toMatchObject({ ok: false, error: { details: { endedLeases } } });
});

test('a request that ended no lease replies without endedLeases', async () => {
const response = await reportEndedLeases(request('snapshot'), async () => ({
ok: true,
data: {},
}));

expect(response).toEqual({ ok: true, data: {} });
});

test('a nested request records on its delegating ledger and reports nothing itself', async () => {
const ledger: EndedLeaseLedger = [];
const nested = onDelegatingLedger(request('close'), {
...request('batch'),
internal: { endedLeases: ledger },
});

const response = await reportEndedLeases(nested, async (req) => {
recordEndedLease(req, 'lease-1');
return { ok: true, data: {} };
});

expect(response).toEqual({ ok: true, data: {} });
expect(ledger).toEqual([{ leaseId: 'lease-1', outcome: 'released' }]);
});

test('a replay reports the lease its script close ended', async () => {
const { root, handler, endedLeases } = leasedDaemon();
const scriptPath = path.join(root, 'close.ad');
fs.writeFileSync(scriptPath, 'close\n');

const response = await handler(
request('replay', [scriptPath], {
replayScriptSource: replayScriptSourceBundleFor(scriptPath),
}),
);

expect(response).toMatchObject({ ok: true, data: { endedLeases } });
});
6 changes: 6 additions & 0 deletions packages/daemon/src/core/daemon-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
} from '@agent-device/kernel/contracts';
import type { DaemonWireRequest } from '@agent-device/contracts/command';
import type { RequestDispatchLedger } from './request-dispatch-ledger.ts';
import type { EndedLeaseLedger } from './request-ended-leases.ts';

/**
* The daemon's own request and response vocabulary: the wire shape from `@agent-device/contracts/command` (`DaemonWireRequest`)
Expand Down Expand Up @@ -60,6 +61,11 @@ type DaemonRequestInternal = ReplayDispatchOptions & {
* delegated it so those mutations count toward that request's disclosure too.
*/
dispatchLedger?: RequestDispatchLedger;
/**
* The leases the outermost request and its nested requests ended. Only the request that created
* it reports it, once, on its reply.
*/
endedLeases?: EndedLeaseLedger;
};

/**
Expand Down
40 changes: 40 additions & 0 deletions packages/daemon/src/core/request-ended-leases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { EndedLease } from '@agent-device/contracts/client';
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from './daemon-request.ts';

/** The session leases one request ended, its nested requests' included. */
export type EndedLeaseLedger = EndedLease[];

/** Records a lease `req` ended on the ledger of the outermost request it runs under. */
export function recordEndedLease(req: DaemonRequest, leaseId: string): void {
req.internal?.endedLeases?.push({ leaseId, outcome: 'released' });
}

/**
* Runs `handle` for an outermost request on a ledger of its own and reports the leases it ended on
* its reply. A nested request already carries its delegating request's ledger and reports nothing.
*/
export async function reportEndedLeases(
req: DaemonRequest,
handle: (req: DaemonRequest) => Promise<DaemonResponse>,
): Promise<DaemonResponse> {
if (req.internal?.endedLeases) return await handle(req);
const endedLeases: EndedLeaseLedger = [];
const response = await handle({ ...req, internal: { ...req.internal, endedLeases } });
if (endedLeases.length === 0) return response;
if (response.ok) return { ok: true, data: { ...response.data, endedLeases } };
return {
ok: false,
error: { ...response.error, details: { ...response.error.details, endedLeases } },
};
}

/** `req` recording its ended leases on the ledger of `parent`, the request that delegated it. */
export function onDelegatingLedger(req: DaemonRequest, parent: DaemonRequest): DaemonRequest {
const endedLeases = parent.internal?.endedLeases;
return endedLeases ? { ...req, internal: { ...req.internal, endedLeases } } : req;
}

/** Each nested request `invoke` runs records its ended leases on the ledger of `parent`. */
export function handDownEndedLeases(invoke: DaemonInvokeFn, parent: DaemonRequest): DaemonInvokeFn {
return async (req) => await invoke(onDelegatingLedger(req, parent));
}
10 changes: 9 additions & 1 deletion packages/daemon/src/server/request-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
refusedBeforeDispatch,
} from '../execution/request-dispatch-disclosure.ts';
import { recordNestedRequests } from '../core/request-dispatch-ledger.ts';
import { handDownEndedLeases, reportEndedLeases } from '../core/request-ended-leases.ts';
import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation';
import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface';
import type { PlatformResourceCleanup } from '../core/platform-resource-cleanup.ts';
Expand Down Expand Up @@ -213,6 +214,10 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
: deps.deviceInventoryGateways;

async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
return await reportEndedLeases(req, handleRequestOnLedger);
}

async function handleRequestOnLedger(req: DaemonRequest): Promise<DaemonResponse> {
const start = Date.now();
const debug = Boolean(req.meta?.debug || req.flags?.verbose);
return await withDiagnosticsScope(
Expand Down Expand Up @@ -385,7 +390,10 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
cloudArtifactProvider,
endSessionsForLease,
providerAppCatalog,
invoke: recordNestedRequests(handleRequest, dispatchLedger),
invoke: handDownEndedLeases(
recordNestedRequests(handleRequest, dispatchLedger),
lockedScope.req,
),
invokeReplayAction: allowReplayActions
? recordNestedRequests(
createReplayScopedActionInvoker(lockedScope, providerScope),
Expand Down
21 changes: 17 additions & 4 deletions packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ test('releaseSessionLease releases with the stored session owner scope', async (
},
});

const provider = await releaseSessionLease({
const released = await releaseSessionLease({
session,
leaseRegistry,
leaseLifecycleProvider: {
Expand All @@ -127,7 +127,7 @@ test('releaseSessionLease releases with the stored session owner scope', async (
});

expect(leaseRegistry.listActiveLeases()).toHaveLength(0);
expect(provider).toEqual({ provider: 'proxy' });
expect(released).toEqual({ endedLeaseId: lease.leaseId, providerData: { provider: 'proxy' } });
});

test('releaseSessionLease leaves a retainOnClose lease and its provider device alone', async () => {
Expand All @@ -149,13 +149,13 @@ test('releaseSessionLease leaves a retainOnClose lease and its provider device a
});
const release = vi.fn(async () => ({ released: true }));

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

expect(provider).toBeUndefined();
expect(retained).toEqual({});
expect(release).not.toHaveBeenCalled();
expect(leaseRegistry.listActiveLeases()).toHaveLength(1);
expect(vi.mocked(emitDiagnostic)).toHaveBeenCalledWith({
Expand All @@ -165,6 +165,19 @@ test('releaseSessionLease leaves a retainOnClose lease and its provider device a
});
});

test('releaseSessionLease ends no lease for a session that holds none', async () => {
const release = vi.fn(async () => ({ released: true }));

const outcome = await releaseSessionLease({
session: makeIosSession('default'),
leaseRegistry: new LeaseRegistry(),
leaseLifecycleProvider: { release },
});

expect(outcome).toEqual({});
expect(release).not.toHaveBeenCalled();
});

test('releaseSessionLease retains provider session ownership for artifact lookup', async () => {
const leaseRegistry = new LeaseRegistry();
const lease = leaseRegistry.allocateLease({
Expand Down
Loading
Loading