diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index d386648e08..70f6c87c10 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -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 diff --git a/packages/contracts/src/__tests__/lease-scope.test.ts b/packages/contracts/src/__tests__/lease-scope.test.ts index 3da34f2be7..364fbee930 100644 --- a/packages/contracts/src/__tests__/lease-scope.test.ts +++ b/packages/contracts/src/__tests__/lease-scope.test.ts @@ -5,6 +5,7 @@ import { isInactiveLeaseError, leaseScopeFromOptions, leaseScopeFromRequest, + leaseScopeToAllocateRequest, leaseScopeToCommandFlags, leaseScopeToConnectionMetadata, leaseScopeToLeaseRpcParams, @@ -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({ diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index 6f5e6e6656..7496ac9158 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -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; @@ -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 & { diff --git a/packages/contracts/src/device-provider.ts b/packages/contracts/src/device-provider.ts index ac49605c38..762e9ea00a 100644 --- a/packages/contracts/src/device-provider.ts +++ b/packages/contracts/src/device-provider.ts @@ -17,6 +17,7 @@ export type DeviceLease = { leaseProvider?: string; deviceKey?: string; clientId?: string; + retainOnClose?: true; createdAt: number; heartbeatAt: number; expiresAt: number; diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 51bb05550c..e365a472e5 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -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; +export type LeaseDiagnosticsContext = Omit; export type LeaseRpcCommand = 'lease_allocate' | 'lease_heartbeat' | 'lease_release'; @@ -36,6 +37,7 @@ export type LeaseAllocateRequestScope = { deviceKey?: string; clientId?: string; ttlMs?: number; + retainOnClose?: boolean; }; export type LeaseScopedRequestScope = { @@ -56,6 +58,7 @@ type LeaseRequestLike = { runId?: string; leaseId?: string; leaseTtlMs?: number; + leaseRetainOnClose?: boolean; leaseBackend?: LeaseBackend; leaseProvider?: string; deviceKey?: string; @@ -69,6 +72,7 @@ type LeaseOptionsLike = { leaseId?: string; leaseTtlMs?: number; ttlMs?: number; + retainOnClose?: boolean; leaseBackend?: LeaseBackend; leaseProvider?: string; provider?: string; @@ -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 ?? @@ -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, @@ -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, @@ -139,6 +146,7 @@ export function leaseScopeToAllocateRequest(scope: LeaseScope): LeaseAllocateReq deviceKey: scope.deviceKey, clientId: scope.clientId, ttlMs: scope.leaseTtlMs, + retainOnClose: scope.leaseRetainOnClose, }) as LeaseAllocateRequestScope; } @@ -177,6 +185,7 @@ export function leaseScopeToLeaseRpcParams( ...common, ...stripUndefined({ ttlMs: scope.leaseTtlMs, + retainOnClose: scope.leaseRetainOnClose, backend: scope.leaseBackend, }), }; diff --git a/packages/kernel/src/contracts.ts b/packages/kernel/src/contracts.ts index ec5adba62c..0649955053 100644 --- a/packages/kernel/src/contracts.ts +++ b/packages/kernel/src/contracts.ts @@ -143,6 +143,7 @@ export type DaemonRequestMeta = { runId?: string; leaseId?: string; leaseTtlMs?: number; + leaseRetainOnClose?: boolean; leaseBackend?: LeaseBackend; leaseProvider?: string; deviceKey?: string; diff --git a/src/client/lease-client.test.ts b/src/client/lease-client.test.ts index cfcf9dfcb0..3738bdb0ae 100644 --- a/src/client/lease-client.test.ts +++ b/src/client/lease-client.test.ts @@ -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> = []; + 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); +}); diff --git a/src/client/lease-client.ts b/src/client/lease-client.ts index f914cc29f4..f1d1dd903c 100644 --- a/src/client/lease-client.ts +++ b/src/client/lease-client.ts @@ -83,6 +83,7 @@ function normalizeLease(data: Record): 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, diff --git a/src/daemon/__tests__/daemon-runtime-app-log.test.ts b/src/daemon/__tests__/daemon-runtime-app-log.test.ts index 08c46cb91b..b91d679861 100644 --- a/src/daemon/__tests__/daemon-runtime-app-log.test.ts +++ b/src/daemon/__tests__/daemon-runtime-app-log.test.ts @@ -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', diff --git a/src/daemon/__tests__/http-server-rpc-validation.test.ts b/src/daemon/__tests__/http-server-rpc-validation.test.ts index 925660d288..3d2add7b77 100644 --- a/src/daemon/__tests__/http-server-rpc-validation.test.ts +++ b/src/daemon/__tests__/http-server-rpc-validation.test.ts @@ -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 => { + 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-'); diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index 365db14d47..04e8167799 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -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'; @@ -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(); + 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-'); @@ -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({ diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index 33852bfe44..5b347e5781 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -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({ diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index d182e86469..e73f702fcb 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -430,6 +430,54 @@ test('close releases the session lease', async () => { expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); +test.each([ + ['close', []], + ['close ', ['com.example.app']], +])('%s keeps a lease allocated with retainOnClose', async (_name, positionals) => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + clientId: 'client-a', + retainOnClose: true, + }); + sessionStore.publish('default', { + name: 'default', + device: makeIosDevice('SIM-CLOSE-RETAIN'), + createdAt: Date.now(), + actions: [], + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + clientId: 'client-a', + }, + }); + const handler = createOpenHandler(sessionStore, leaseRegistry); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'close', + positionals, + meta: { requestId: 'req-close-retain-lease' }, + }); + + expect(response.ok).toBe(true); + expect(sessionStore.get('default')).toBeUndefined(); + expect(leaseRegistry.listActiveLeases()).toHaveLength(1); + expect( + leaseRegistry.heartbeatLease({ + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + clientId: 'client-a', + }).leaseId, + ).toBe(lease.leaseId); +}); + test('close fails synchronously when root composition omits platform resource cleanup', async () => { const sessionStore = makeSessionStore('agent-device-router-open-'); sessionStore.publish('default', { diff --git a/src/daemon/handlers/__tests__/lease.test.ts b/src/daemon/handlers/__tests__/lease.test.ts index 28346814a9..89217ff892 100644 --- a/src/daemon/handlers/__tests__/lease.test.ts +++ b/src/daemon/handlers/__tests__/lease.test.ts @@ -50,6 +50,65 @@ for (const operation of ['allocate', 'release'] as const) { }); } +test('lease_release still releases a retainOnClose lease through the provider', async () => { + const registry = new LeaseRegistry(); + const lease = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + clientId: 'client-a', + retainOnClose: true, + }); + const released: DeviceLease[] = []; + const request: DaemonRequest = { + token: 'test-token', + session: 'default', + command: 'lease_release', + positionals: [], + meta: { tenantId: 'tenant-a', runId: 'run-1', leaseId: lease.leaseId, clientId: 'client-a' }, + }; + + const response = await handleLeaseCommands({ + req: request, + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-retained-release-'), + leaseRegistry: registry, + leaseLifecycleProvider: { + release: async (active) => { + released.push(active); + return { providerSessionId: 'provider-1' }; + }, + }, + }); + + assert.equal(response?.ok, true); + assert.deepEqual( + released.map((active) => active.leaseId), + [lease.leaseId], + ); + assert.equal(registry.listActiveLeases().length, 0); +}); + +test('lease_allocate stores retainOnClose from the request meta', async () => { + const registry = new LeaseRegistry(); + const request: DaemonRequest = { + token: 'test-token', + session: 'default', + command: 'lease_allocate', + positionals: [], + meta: { tenantId: 'tenant-a', runId: 'run-1', leaseRetainOnClose: true }, + }; + + const response = await handleLeaseCommands({ + req: request, + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-retained-allocate-'), + leaseRegistry: registry, + }); + + assert.equal(response?.ok, true); + assert.equal(registry.listActiveLeases()[0]?.retainOnClose, true); +}); + test('activation drains canceled provider allocation and its release cleanup', async () => { const registry = new LeaseRegistry(); const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 0aaac48f63..18a3b348a5 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -10,7 +10,7 @@ import type { } from '@agent-device/contracts/observability'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; -import type { ReleaseLeaseRequest } from '../lease-registry-scope.ts'; +import { leaseReleaseRequestFor, type ReleaseLeaseRequest } from '../lease-registry-scope.ts'; import type { SessionStore } from '../session-store.ts'; import { isProxyLeaseScope, @@ -147,18 +147,6 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise 0, + hasActiveLeases: () => false, onIdleReap: () => { reaped++; }, @@ -163,6 +164,29 @@ test('idle reap fires after the idle window when nothing is using the daemon', a assert.equal(reaped, 1); }); +test('idle reap waits another window while a lease no session holds is still active', async () => { + vi.useFakeTimers(); + let reaped = 0; + let leaseActive = true; + const idleReap = createDaemonIdleReap({ + sessionStore, + getInFlightRequestCount: () => 0, + hasActiveLeases: () => leaseActive, + onIdleReap: () => { + reaped++; + }, + env: { AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '40' }, + }); + + idleReap.noteActivity(); + await vi.advanceTimersByTimeAsync(120); + assert.equal(reaped, 0); + leaseActive = false; + await vi.advanceTimersByTimeAsync(40); + + assert.equal(reaped, 1); +}); + test('idle reap does not fire while a session is open', async () => { vi.useFakeTimers(); let reaped = 0; @@ -170,6 +194,7 @@ test('idle reap does not fire while a session is open', async () => { const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, + hasActiveLeases: () => false, onIdleReap: () => { reaped++; }, @@ -198,6 +223,7 @@ test('idle reap does not fire while a recording is active', async () => { const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, + hasActiveLeases: () => false, onIdleReap: () => { reaped++; }, @@ -217,6 +243,7 @@ test('idle reap does not fire while a request is in flight', async () => { const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => inFlightRequestCount, + hasActiveLeases: () => false, onIdleReap: () => { reaped++; }, @@ -239,6 +266,7 @@ test('idle reap is disabled when the window is zero', async () => { const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, + hasActiveLeases: () => false, onIdleReap: () => { reaped++; }, @@ -257,6 +285,7 @@ test('cancel prevents a scheduled reap from firing', async () => { const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, + hasActiveLeases: () => false, onIdleReap: () => { reaped++; }, diff --git a/src/daemon/server/daemon-idle-reap.ts b/src/daemon/server/daemon-idle-reap.ts index 1b35ba5a46..8526c33164 100644 --- a/src/daemon/server/daemon-idle-reap.ts +++ b/src/daemon/server/daemon-idle-reap.ts @@ -83,6 +83,11 @@ export type DaemonIdleReapController = { export function createDaemonIdleReap(params: { sessionStore: SessionStore; getInFlightRequestCount: () => number; + /** + * An unexpired lease is a client's claim on this daemon, so a reap that finds one waits another + * idle window. The check must also expire leases past their window, or none would ever end. + */ + hasActiveLeases: () => boolean; onIdleReap: () => void; env?: NodeJS.ProcessEnv; }): DaemonIdleReapController { @@ -110,6 +115,10 @@ export function createDaemonIdleReap(params: { // session open or new request must never lose a race against a // previously scheduled reap. if (!isIdleNow()) return; + if (params.hasActiveLeases()) { + schedule(); + return; + } emitDiagnostic({ level: 'info', phase: 'daemon_idle_reap', data: { idleMs } }); params.onIdleReap(); }, idleMs); diff --git a/src/daemon/server/daemon-session-lease-finalizer.test.ts b/src/daemon/server/daemon-lease-finalizer.test.ts similarity index 68% rename from src/daemon/server/daemon-session-lease-finalizer.test.ts rename to src/daemon/server/daemon-lease-finalizer.test.ts index ac869bc0de..de17febae5 100644 --- a/src/daemon/server/daemon-session-lease-finalizer.test.ts +++ b/src/daemon/server/daemon-lease-finalizer.test.ts @@ -1,14 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; -import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import type { DeviceLease } from '@agent-device/contracts/device'; import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; -import { finalizeDaemonSessionLease } from './daemon-session-lease-finalizer.ts'; +import { finalizeDaemonLeases } from './daemon-lease-finalizer.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -test('journals and bounds a hung recoverable session lease release before the final drain', async () => { +test('journals and bounds a hung recoverable lease release before the final drain', async () => { vi.useFakeTimers(); const stateDir = mkdtempForTestSync('agent-device-daemon-lease-finalizer-'); const leaseRegistry = new LeaseRegistry(); @@ -16,6 +15,7 @@ test('journals and bounds a hung recoverable session lease release before the fi tenantId: 'tenant-a', runId: 'run-1', leaseProvider: 'limrun', + retainOnClose: true, }); const recoverExpiredLease = vi.fn(() => new Promise(() => {})); const expiredProviderLeaseReleaser = createExpiredProviderLeaseReleaser({ @@ -23,21 +23,10 @@ test('journals and bounds a hung recoverable session lease release before the fi recoverableProviderIds: ['limrun'], stateDir, }); - const session = makeIosSession('default', { - lease: { - leaseId: lease.leaseId, - tenantId: lease.tenantId, - runId: lease.runId, - leaseBackend: lease.backend, - leaseProvider: lease.leaseProvider, - expiresAt: lease.expiresAt, - }, - }); try { expiredProviderLeaseReleaser.beginShutdown(); - const finalization = finalizeDaemonSessionLease({ - session, + const finalization = finalizeDaemonLeases({ leaseRegistry, expiredProviderLeaseReleaser, timeoutMs: 10, @@ -62,7 +51,7 @@ test('journals and bounds a hung recoverable session lease release before the fi } }); -test('final drain joins a release that completes after the session timeout', async () => { +test('final drain joins a release that completes after the lease timeout', async () => { vi.useFakeTimers(); const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ @@ -80,21 +69,10 @@ test('final drain joins a release that completes after the session timeout', asy leaseLifecycleProvider: { release }, providerRuntimeIds: ['browserstack'], }); - const session = makeIosSession('default', { - lease: { - leaseId: lease.leaseId, - tenantId: lease.tenantId, - runId: lease.runId, - leaseBackend: lease.backend, - leaseProvider: lease.leaseProvider, - expiresAt: lease.expiresAt, - }, - }); try { expiredProviderLeaseReleaser.beginShutdown(); - const finalization = finalizeDaemonSessionLease({ - session, + const finalization = finalizeDaemonLeases({ leaseRegistry, expiredProviderLeaseReleaser, timeoutMs: 1_000, @@ -112,7 +90,7 @@ test('final drain joins a release that completes after the session timeout', asy } }); -test('a hung provider release does not starve a later session during shutdown', async () => { +test('a hung provider release does not starve another lease during shutdown', async () => { vi.useFakeTimers(); const leaseRegistry = new LeaseRegistry(); const hungLease = leaseRegistry.allocateLease({ @@ -134,24 +112,14 @@ test('a hung provider release does not starve a later session during shutdown', leaseLifecycleProvider: { release }, providerRuntimeIds: ['browserstack'], }); - const sessions = [ - makeIosSession('hung', { lease: sessionLease(hungLease) }), - makeIosSession('released', { lease: sessionLease(releasedLease) }), - ]; try { expiredProviderLeaseReleaser.beginShutdown(); - const finalization = Promise.all( - sessions.map( - async (session) => - await finalizeDaemonSessionLease({ - session, - leaseRegistry, - expiredProviderLeaseReleaser, - timeoutMs: 1_000, - }), - ), - ); + const finalization = finalizeDaemonLeases({ + leaseRegistry, + expiredProviderLeaseReleaser, + timeoutMs: 1_000, + }); await vi.advanceTimersByTimeAsync(1_000); await finalization; @@ -167,14 +135,3 @@ test('a hung provider release does not starve a later session during shutdown', vi.useRealTimers(); } }); - -function sessionLease(lease: DeviceLease) { - return { - leaseId: lease.leaseId, - tenantId: lease.tenantId, - runId: lease.runId, - leaseBackend: lease.backend, - leaseProvider: lease.leaseProvider, - expiresAt: lease.expiresAt, - }; -} diff --git a/src/daemon/server/daemon-lease-finalizer.ts b/src/daemon/server/daemon-lease-finalizer.ts new file mode 100644 index 0000000000..a8607b4a2e --- /dev/null +++ b/src/daemon/server/daemon-lease-finalizer.ts @@ -0,0 +1,60 @@ +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import type { ExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; +import type { LeaseRegistry } from '../lease-registry.ts'; +import { leaseReleaseRequestFor } from '../lease-registry-scope.ts'; + +/** + * Ends every lease still active at daemon shutdown, whether a session held it or its owner kept it + * through `close` with `retainOnClose`. Each provider release is bounded by `timeoutMs`; one that + * runs past it stays with the releaser for the shutdown drain. + */ +export async function finalizeDaemonLeases(params: { + leaseRegistry: LeaseRegistry; + expiredProviderLeaseReleaser: ExpiredProviderLeaseReleaser; + timeoutMs: number; +}): Promise { + const { leaseRegistry, expiredProviderLeaseReleaser, timeoutMs } = params; + await Promise.all( + leaseRegistry.listActiveLeases().map(async (lease) => { + try { + const completed = await releaseWithinTimeout( + expiredProviderLeaseReleaser.release(lease), + timeoutMs, + ); + leaseRegistry.releaseLease(leaseReleaseRequestFor(lease)); + if (!completed) { + emitDiagnostic({ + level: 'warn', + phase: 'daemon_shutdown_lease_release_timed_out', + data: { leaseId: lease.leaseId, timeoutMs }, + }); + } + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'daemon_shutdown_lease_release_failed', + data: { + leaseId: lease.leaseId, + error: error instanceof Error ? error.message : String(error), + }, + }); + } + }), + ); +} + +async function releaseWithinTimeout(release: Promise, timeoutMs: number): Promise { + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => resolve(false), timeoutMs); + void release.then( + () => { + clearTimeout(timeout); + resolve(true); + }, + (error: unknown) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} diff --git a/src/daemon/server/daemon-runtime-device-claims.test.ts b/src/daemon/server/daemon-runtime-device-claims.test.ts index 86c91fb19f..b08042b233 100644 --- a/src/daemon/server/daemon-runtime-device-claims.test.ts +++ b/src/daemon/server/daemon-runtime-device-claims.test.ts @@ -40,43 +40,37 @@ function setup(): { session: SessionState; sessionStore: SessionStore; stateDir: return { session, sessionStore, stateDir }; } -test('finalizes provider state but does not clear a claim after shutdown teardown rejects', async () => { +test('does not clear a claim after shutdown teardown rejects', async () => { const { session, sessionStore } = setup(); mockTeardownSessionResources.mockRejectedValueOnce(new Error('teardown failed')); - const beforeDelete = vi.fn(async () => {}); const afterSuccessfulTeardown = vi.fn(async () => {}); await teardownDaemonSessionForShutdown({ ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: () => {} }, - beforeDelete, afterSuccessfulTeardown, }); - expect(beforeDelete).toHaveBeenCalledWith(session); expect(afterSuccessfulTeardown).not.toHaveBeenCalled(); expect(sessionStore.get(session.name)).toBeUndefined(); }); -test('finalizes provider state but does not clear a claim after shutdown teardown times out', async () => { +test('does not clear a claim after shutdown teardown times out', async () => { vi.useFakeTimers(); const { session, sessionStore } = setup(); mockTeardownSessionResources.mockReturnValueOnce(new Promise(() => {})); - const beforeDelete = vi.fn(async () => {}); const afterSuccessfulTeardown = vi.fn(async () => {}); const teardown = teardownDaemonSessionForShutdown({ ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: () => {} }, - beforeDelete, afterSuccessfulTeardown, }); await vi.advanceTimersByTimeAsync(5_000); await teardown; - expect(beforeDelete).toHaveBeenCalledWith(session); expect(afterSuccessfulTeardown).not.toHaveBeenCalled(); expect(sessionStore.get(session.name)).toBeUndefined(); }); diff --git a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts index fe721377b3..ac90fcc75d 100644 --- a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts +++ b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts @@ -90,6 +90,41 @@ vi.mock('../application-lifecycle-recovery.ts', async (importOriginal) => ({ ...(await importOriginal()), finalizeDaemonSessionApplicationLifecycle: shutdownProbe.finalize, })); +const leaseProbe = vi.hoisted(() => ({ + registries: [] as import('../lease-registry.ts').LeaseRegistry[], + released: [] as import('@agent-device/contracts/device').DeviceLease[], +})); + +vi.mock('../lease-registry.ts', async (importOriginal) => { + const actual = await importOriginal(); + class RecordedLeaseRegistry extends actual.LeaseRegistry { + constructor(...args: ConstructorParameters) { + super(...args); + leaseProbe.registries.push(this); + } + } + return { ...actual, LeaseRegistry: RecordedLeaseRegistry }; +}); + +vi.mock('../provider-lease-expiry.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createExpiredProviderLeaseReleaser: ( + ...args: Parameters + ) => { + const releaser = actual.createExpiredProviderLeaseReleaser(...args); + return { + ...releaser, + release: async (lease: import('@agent-device/contracts/device').DeviceLease) => { + leaseProbe.released.push(lease); + await releaser.release(lease); + }, + }; + }, + }; +}); + import { acquireDeviceClaim } from '../device/device-claims.ts'; import { resolveDeviceClaimPath } from '../device/device-claim-paths.ts'; import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; @@ -109,6 +144,8 @@ afterEach(() => { shutdownProbe.dispatch = undefined; shutdownProbe.closeTimeout = undefined; shutdownProbe.finalize.mockReset(); + leaseProbe.registries.length = 0; + leaseProbe.released.length = 0; }); test('daemon shutdown detaches before session teardown and force-finalizes only after gateway resources', async () => { @@ -361,3 +398,36 @@ test.each([false, true])( } }, ); + +test('daemon shutdown releases a retainOnClose lease that no session holds', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-retained-lease-shutdown-'); + try { + const runtime = await startDaemonRuntime({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + }); + expect(runtime).not.toBeNull(); + const [leaseRegistry] = leaseProbe.registries; + const lease = leaseRegistry!.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'limrun', + retainOnClose: true, + }); + + await runtime?.shutdown(); + + expect(leaseProbe.released).toEqual([lease]); + expect(leaseRegistry!.listActiveLeases()).toEqual([]); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 2836d89a88..2513f2a992 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -40,7 +40,7 @@ import type { RuntimeHintValues } from '@agent-device/contracts/application-life import { createDaemonIdleReap } from './daemon-idle-reap.ts'; import { createSessionIdleExpiry } from './daemon-session-idle-expiry.ts'; import { resolveSessionIdleExpiryMs } from '../session-idle-expiry.ts'; -import { finalizeDaemonSessionLease } from './daemon-session-lease-finalizer.ts'; +import { finalizeDaemonLeases } from './daemon-lease-finalizer.ts'; import { processOwnsActiveDeviceClaim, reconcileOrphanedDeviceClaims, @@ -93,7 +93,7 @@ import { recoverAppLogResourcesAfterDaemonLock } from '../app-log-resource-recov import { createDaemonRecoveryPlatformScope } from '../platform-request-scope.ts'; import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; -const DAEMON_SESSION_LEASE_RELEASE_TIMEOUT_MS = 1_000; +const DAEMON_LEASE_RELEASE_TIMEOUT_MS = 1_000; const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000; const DAEMON_PROVIDER_RELEASE_DRAIN_TIMEOUT_MS = 2_000; // An orphaned `simctl recordVideo` releases the host-wide recording lock only after it finishes @@ -142,7 +142,6 @@ export async function teardownDaemonSessionForShutdown(params: { session: SessionState, runtimeHints: RuntimeHintValues, ) => Promise; - beforeDelete?: (session: SessionState) => Promise; afterSuccessfulTeardown?: (session: SessionState) => Promise; }): Promise { const { @@ -151,7 +150,6 @@ export async function teardownDaemonSessionForShutdown(params: { stateDir, stderr, finalizeApplicationLifecycle, - beforeDelete, afterSuccessfulTeardown, } = params; const current = sessionStore.resolveCurrent(ref); @@ -208,7 +206,6 @@ export async function teardownDaemonSessionForShutdown(params: { // `.ad` iff the repair transaction completed, else leave a bounded // `REPAIR_SESSION_EXPIRED` tombstone for the reaped-before-finalize case. sessionStore.finalizeRepairTeardown(ref); - await beforeDelete?.(session); if (teardownSucceeded) await afterSuccessfulTeardown?.(session); sessionStore.retire(ref); } @@ -494,14 +491,6 @@ export async function startDaemonRuntime( stateDir: baseDir, runtimeHints, }), - beforeDelete: async (sessionToFinalize) => { - await finalizeDaemonSessionLease({ - session: sessionToFinalize, - leaseRegistry, - expiredProviderLeaseReleaser, - timeoutMs: DAEMON_SESSION_LEASE_RELEASE_TIMEOUT_MS, - }); - }, afterSuccessfulTeardown: shutdownClaimLedger.releaseClaim, }); } finally { @@ -517,7 +506,7 @@ export async function startDaemonRuntime( // #2833: settles the resources of a session this daemon expires for idleness. Deliberately NOT // `teardownDaemonSession`: that one exists for a daemon that is leaving, so it hands a healthy - // execution host to its successor, finalizes a remote lease, and deletes the session whether the + // execution host to its successor and deletes the session whether the // bounded teardown finished or not. An idle expiry is the opposite situation — the daemon is // staying alive, there is no successor, and a session whose resources would not release has to // survive so the next pass can retry rather than leave a claim owned by a process that no longer @@ -590,6 +579,7 @@ export async function startDaemonRuntime( const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => inFlightRequests.size, + hasActiveLeases: () => leaseRegistry.listActiveLeases().length > 0, onIdleReap: () => { void shutdown(); }, @@ -812,6 +802,17 @@ export async function startDaemonRuntime( } catch {} expiredProviderLeaseReleaser.beginShutdown(); await teardownDaemonSessions(); + await withDiagnosticsScope( + { command: 'daemon', session: 'daemon', logPath, debug: true }, + async () => { + await finalizeDaemonLeases({ + leaseRegistry, + expiredProviderLeaseReleaser, + timeoutMs: DAEMON_LEASE_RELEASE_TIMEOUT_MS, + }); + flushDiagnosticsToSessionFile({ force: true }); + }, + ); try { await platformDaemonLifecycleOwners.resetAndroidSnapshotHelper(); } catch (error) { diff --git a/src/daemon/server/daemon-session-lease-finalizer.ts b/src/daemon/server/daemon-session-lease-finalizer.ts deleted file mode 100644 index c7a93bcfda..0000000000 --- a/src/daemon/server/daemon-session-lease-finalizer.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { leaseScopeToReleaseRequest } from '@agent-device/contracts/lease-scope'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import type { ExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; -import type { LeaseRegistry } from '../lease-registry.ts'; -import type { SessionState } from '../session-state.ts'; - -export async function finalizeDaemonSessionLease(params: { - session: SessionState; - leaseRegistry: LeaseRegistry; - expiredProviderLeaseReleaser: ExpiredProviderLeaseReleaser; - timeoutMs: number; -}): Promise { - const { session, leaseRegistry, expiredProviderLeaseReleaser, timeoutMs } = params; - if (!session.lease) return; - try { - const releaseRequest = leaseScopeToReleaseRequest({ - leaseId: session.lease.leaseId, - tenantId: session.lease.tenantId, - runId: session.lease.runId, - leaseBackend: session.lease.leaseBackend, - leaseProvider: session.lease.leaseProvider, - deviceKey: session.lease.deviceKey, - clientId: session.lease.clientId, - }); - const activeLease = leaseRegistry.getLease(releaseRequest); - if (!activeLease) return; - const completed = await releaseWithinTimeout( - expiredProviderLeaseReleaser.release(activeLease), - timeoutMs, - ); - leaseRegistry.releaseLease(releaseRequest); - if (!completed) { - emitDiagnostic({ - level: 'warn', - phase: 'daemon_shutdown_session_lease_release_timed_out', - data: { - session: session.name, - leaseId: session.lease.leaseId, - timeoutMs, - }, - }); - } - } catch (error) { - emitDiagnostic({ - level: 'warn', - phase: 'daemon_shutdown_session_lease_release_failed', - data: { - session: session.name, - leaseId: session.lease.leaseId, - error: error instanceof Error ? error.message : String(error), - }, - }); - } -} - -async function releaseWithinTimeout(release: Promise, timeoutMs: number): Promise { - return await new Promise((resolve, reject) => { - const timeout = setTimeout(() => resolve(false), timeoutMs); - void release.then( - () => { - clearTimeout(timeout); - resolve(true); - }, - (error: unknown) => { - clearTimeout(timeout); - reject(error); - }, - ); - }); -} diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index e802cae675..b0d78ba083 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -329,6 +329,7 @@ function toLeaseDaemonRequest( runId: readStringParam(params, 'runId'), leaseId: readStringParam(params, 'leaseId'), leaseTtlMs: readIntParam(params, 'ttlMs'), + leaseRetainOnClose: readBooleanParam(params, 'retainOnClose'), leaseBackend: readStringParam(params, 'backend') as LeaseBackend | undefined, leaseProvider: readStringParam(params, 'leaseProvider') ?? readStringParam(params, 'provider'), diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 599923237e..2a7829226c 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -21,7 +21,7 @@ "packages/kernel/src/contracts.ts#DaemonInstallSource": "sha256:fe26e202e62d997cdd3855fd96002511783a6c26a9ce27210d7c6cc084c66e4e", "packages/kernel/src/contracts.ts#DaemonLockPolicy": "sha256:f33d0ce7200a7d97c11e91bd2889b5d9bc7f35935a5370a29930118d091468f4", "packages/kernel/src/contracts.ts#DaemonRequest": "sha256:911d93245b8dc557db897c4cac051030db673711bf3af51baa8b82248d509c57", - "packages/kernel/src/contracts.ts#DaemonRequestMeta": "sha256:58f71959e597ceb3d6284d4b68533c6c6bbc214c069ffefa4a1a47be068e2314", + "packages/kernel/src/contracts.ts#DaemonRequestMeta": "sha256:668e635f88f651f6cebeb5f6505976729e598c0be35327ad00f314f151cb601c", "packages/kernel/src/contracts.ts#DaemonResponse": "sha256:5f6cc08ed43f40c1cf17e33dd2b6b879dda1ac383de9c6c9a2c653929c21346b", "packages/kernel/src/contracts.ts#DaemonResponseData": "sha256:f16e61b7f0ea8cdae82349508383af2f97a4c16c8aa21952d833094673f86afc", "packages/kernel/src/contracts.ts#JsonRpcId": "sha256:5a5fdff1cd85971214117d69a8278d0ecaf21a6fa3f4582155d9b3d9cad1fbd0", @@ -121,7 +121,7 @@ "src/daemon/server/http-server.ts#sendJson": "sha256:a1de00da3f1db8c98e69a85dfe2e11b626a4d2ce472840e3b8f519aa8a207a8f", "src/daemon/server/http-server.ts#toDaemonRequest": "sha256:f3861d2d4f67f66e83800616926f6bbb0595de4ecbb0072b5b78971ebeb0705b", "src/daemon/server/http-server.ts#toInstallFromSourceDaemonRequest": "sha256:dff093758b433043816dfc1e2e9658078875e46b887fdc530fb9380158d629f5", - "src/daemon/server/http-server.ts#toLeaseDaemonRequest": "sha256:bee83566f9c5da4ede12b4844fae0930d09439ba7586123609637107b61f8644", + "src/daemon/server/http-server.ts#toLeaseDaemonRequest": "sha256:33052eb8a6521febee30a99d6c281e72fcaf3e7515188f7c926097dfc891d948", "src/daemon/server/http-server.ts#toReleaseMaterializedPathsDaemonRequest": "sha256:708736ca03d6a3fc449bb408382dbd509e490de1f84ecc8771fd77d4adda0154", "src/daemon/server/http-server.ts#writeProgressEnvelope": "sha256:7be778b3902c72d7aa6282c16dc7106fdd64e99c0b5daf9f413ff5dd3fb044f1", "src/daemon/server/http-server.ts#writeRpcResponseEnvelope": "sha256:7a8155e9fcbb53485489728250a85109b5dab0d96cdd4aa8b7e7eb87a4898ddb", @@ -196,8 +196,8 @@ "compatibleChanges": [ { "declaration": "packages/kernel/src/contracts.ts#DaemonRequestMeta", - "digest": "sha256:58f71959e597ceb3d6284d4b68533c6c6bbc214c069ffefa4a1a47be068e2314", - "rationale": "Adds the optional developerDir field: a local client's DEVELOPER_DIR, overlaid on the env of commands the daemon spawns for that request. A released daemon ignores the unknown meta key and keeps its own environment, exactly as before; a released client never sends it, so the new daemon inherits its own environment for that request. The remote HTTP surface strips it." + "digest": "sha256:668e635f88f651f6cebeb5f6505976729e598c0be35327ad00f314f151cb601c", + "rationale": "Adds the optional developerDir field: a local client's DEVELOPER_DIR, overlaid on the env of commands the daemon spawns for that request. A released daemon ignores the unknown meta key and keeps its own environment, exactly as before; a released client never sends it, so the new daemon inherits its own environment for that request. The remote HTTP surface strips it. Also adds the optional leaseRetainOnClose field, sent only on lease_allocate when the caller opts in: a released daemon ignores the unknown meta key and releases the lease on session close as before, and a released client never sends it." }, { "declaration": "packages/kernel/src/contracts.ts#DaemonArtifactKnownType", @@ -226,8 +226,8 @@ }, { "declaration": "src/daemon/server/http-server.ts#toLeaseDaemonRequest", - "digest": "sha256:bee83566f9c5da4ede12b4844fae0930d09439ba7586123609637107b61f8644", - "rationale": "#2110 read the optional providerApp lease-allocation parameter into the existing flags bag; #2494 reads the full provider-allocation flag set into that same bag through the shared readLeaseAllocateProviderFlags projection, so the reconstructed req.flags handed to prepareSession match what the line transport forwards for free. Released protocol-2 clients omit these and follow the unchanged allocation path; no existing field is required or reinterpreted." + "digest": "sha256:33052eb8a6521febee30a99d6c281e72fcaf3e7515188f7c926097dfc891d948", + "rationale": "#2110 read the optional providerApp lease-allocation parameter into the existing flags bag; #2494 reads the full provider-allocation flag set into that same bag through the shared readLeaseAllocateProviderFlags projection, so the reconstructed req.flags handed to prepareSession match what the line transport forwards for free. Released protocol-2 clients omit these and follow the unchanged allocation path; no existing field is required or reinterpreted. The optional retainOnClose boolean parameter is read into meta.leaseRetainOnClose; a released daemon ignores it and keeps releasing on session close." }, { "declaration": "packages/kernel/src/errors.ts#DaemonError", diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index aa130d8a45..076f57e8a4 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -346,7 +346,7 @@ The complete domain-client method map is: - `client.sessions.list()`, `stateDir()`, `close()`, `saveScript()`, `artifacts()` - `client.apps.install()`, `reinstall()`, `installFromSource()`, `list()`, `open()`, `close()`, `push()`, `triggerEvent()` - `client.materializations.release()` -- `client.leases.allocate()`, `heartbeat()`, `release()` +- `client.leases.allocate()`, `heartbeat()`, `release()`. Pass `retainOnClose: true` to `allocate()` when you release the lease yourself; session `close` then leaves it active until `release()`, expiry, or daemon shutdown. The returned lease has `retainOnClose: true` only when the daemon honored it. Keep heartbeating a retained lease between sessions: it expires after its `ttlMs` like any other lease. - `client.metro.prepare()`, `reload()` - `client.capture.snapshot()`, `screenshot()`, `diff()` - `client.interactions.click()`, `press()`, `longPress()`, `swipe()`, `pan()`, `drag()`, `fling()`, `swipeGesture()`, `focus()`, `type()`, `fill()`, `scroll()`, `pinch()`, `rotateGesture()`, `transformGesture()`, `get()`, `is()`, `find()` diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 803522f7ff..b0d48dd7f5 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -45,7 +45,7 @@ agent-device disconnect Passing `--daemon-auth-token ` instead of exporting the environment variable also works, but only authenticates the single command it is passed to; subsequent commands need the token again through the env var, a `daemonAuthToken` entry in your remote config profile, or a repeated `--daemon-auth-token` flag. -`connect proxy` stores the proxy profile and client identity. Device leases are automatic on `open` and expire after five minutes without commands. That five minutes is the window `open` asks for; a lease allocated directly over the RPC without `ttlMs` keeps the daemon's one-minute inactivity default instead. Either window starts when allocation completes, not when it was requested. `close` releases the active session and device lease; `disconnect` clears local connection state. +`connect proxy` stores the proxy profile and client identity. Device leases are automatic on `open` and expire after five minutes without commands. That five minutes is the window `open` asks for; a lease allocated directly over the RPC without `ttlMs` keeps the daemon's one-minute inactivity default instead. Either window starts when allocation completes, not when it was requested. `close` releases the active session and device lease, except a lease allocated with `retainOnClose`, which only `leases.release`, expiry, or daemon shutdown ends; `disconnect` clears local connection state. Multiple agents can share one proxy when each uses the normal `connect proxy`, `open`, commands, `close`, and `disconnect` flow. A busy device error means another agent owns the device until it closes or its inactivity lease expires.