From f34e655c6ba15c6a8b89e1a3bf610c7ae1a1bcea Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Sun, 4 Oct 2026 14:25:54 +0200 Subject: [PATCH 1/7] feat(daemon): keep a caller-owned lease through session close with retainOnClose --- docs/adr/0007-remote-device-leases.md | 6 ++ .../src/__tests__/lease-scope.test.ts | 22 +++++++ packages/contracts/src/client-lease.ts | 2 + packages/contracts/src/device-provider.ts | 1 + packages/contracts/src/lease-scope.ts | 11 +++- packages/kernel/src/contracts.ts | 1 + .../http-server-rpc-validation.test.ts | 33 +++++++++++ src/daemon/__tests__/lease-lifecycle.test.ts | 30 ++++++++++ .../__tests__/request-router-open.test.ts | 45 ++++++++++++++ src/daemon/handlers/__tests__/lease.test.ts | 59 +++++++++++++++++++ src/daemon/lease-lifecycle.ts | 2 + src/daemon/lease-registry-scope.ts | 4 ++ src/daemon/server/http-server.ts | 1 + website/docs/docs/client-api.md | 2 +- website/docs/docs/remote-proxy.md | 2 +- 15 files changed, 218 insertions(+), 3 deletions(-) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index d386648e08..447aeb3de3 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -42,6 +42,12 @@ 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`. + ## 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..3a308064ee 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -32,6 +32,8 @@ export type LeaseAllocateOptions = LeaseOptions & { provider?: string; deviceKey?: string; clientId?: string; + /** Keeps the lease through session `close`; it then ends only through `leases.release` or expiry. */ + 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/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 8fce339a62..d4c9130bcb 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -122,6 +122,36 @@ 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); +}); + test('releaseSessionLease retains provider session ownership for artifact lookup', async () => { const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index ec084f7c75..b10ab6c1cc 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -430,6 +430,51 @@ test('close releases the session lease', async () => { expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); +test('close keeps a lease allocated with retainOnClose', async () => { + 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.set('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.set('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/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index cc70befb02..43ce7a6b77 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -148,6 +148,7 @@ export function resolveSessionLeaseForRequest(params: { ); } +/** Releases the lease a closing session holds, unless its owner allocated it with `retainOnClose`. */ export async function releaseSessionLease(params: { session: SessionState; leaseRegistry: LeaseRegistry; @@ -165,6 +166,7 @@ export async function releaseSessionLease(params: { clientId: lease.clientId, }); const activeLease = params.leaseRegistry.getLease(releaseRequest); + if (activeLease?.retainOnClose) return undefined; const providerData = activeLease ? await params.leaseLifecycleProvider?.release?.(activeLease) : undefined; diff --git a/src/daemon/lease-registry-scope.ts b/src/daemon/lease-registry-scope.ts index fd8cdb285e..fd5cc34d3d 100644 --- a/src/daemon/lease-registry-scope.ts +++ b/src/daemon/lease-registry-scope.ts @@ -23,6 +23,7 @@ export type AllocateLeaseRequest = { deviceKey?: string; clientId?: string; ttlMs?: number; + retainOnClose?: boolean; }; export type HeartbeatLeaseRequest = { @@ -82,6 +83,7 @@ export type NormalizedAllocateLeaseRequest = { deviceKey?: string; clientId?: string; ttlMs?: number; + retainOnClose?: boolean; }; const DEFAULT_LEASE_TTL_MS = 60_000; @@ -204,6 +206,7 @@ export function normalizeAllocateLeaseRequest( tenantId: normalizeRequiredTenantId(request.tenantId), runId: normalizeRequiredRunId(request.runId), ttlMs: request.ttlMs, + retainOnClose: request.retainOnClose, }; } @@ -322,6 +325,7 @@ export function createDeviceLease( ...(request.leaseProvider ? { leaseProvider: request.leaseProvider } : {}), ...(request.deviceKey ? { deviceKey: request.deviceKey } : {}), ...(request.clientId ? { clientId: request.clientId } : {}), + ...(request.retainOnClose ? { retainOnClose: true as const } : {}), createdAt: now, heartbeatAt: now, expiresAt: now + leaseTtlMs, 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/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index b1845ce9bc..3338331c12 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -342,7 +342,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()` or expiry. - `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..d8b299ca6f 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` or expiry 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. From ec9758187dcca3ce643aff0f4ec43236904972fc Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Sun, 4 Oct 2026 14:53:26 +0200 Subject: [PATCH 2/7] fix(daemon): release retained leases at shutdown and honor retainOnClose on lease reuse --- src/daemon/__tests__/lease-registry.test.ts | 15 ++++++ .../__tests__/request-router-open.test.ts | 7 ++- src/daemon/handlers/lease.ts | 14 +---- src/daemon/lease-registry-scope.ts | 17 +++++- src/daemon/lease-registry.ts | 7 ++- src/daemon/server/daemon-runtime.ts | 10 +++- .../daemon-session-lease-finalizer.test.ts | 43 ++++++++++++++- .../server/daemon-session-lease-finalizer.ts | 53 ++++++++++++------- 8 files changed, 128 insertions(+), 38 deletions(-) diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index 33852bfe44..188834d9d3 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -44,6 +44,21 @@ 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('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 b10ab6c1cc..53c45baba7 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -430,7 +430,10 @@ test('close releases the session lease', async () => { expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); -test('close keeps a lease allocated with retainOnClose', async () => { +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({ @@ -458,7 +461,7 @@ test('close keeps a lease allocated with retainOnClose', async () => { token: 'test-token', session: 'default', command: 'close', - positionals: [], + positionals, meta: { requestId: 'req-close-retain-lease' }, }); 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 { @@ -178,3 +181,41 @@ function sessionLease(lease: DeviceLease) { expiresAt: lease.expiresAt, }; } + +test('journals and bounds the release of a lease no session holds', async () => { + vi.useFakeTimers(); + const stateDir = mkdtempForTestSync('agent-device-daemon-lease-finalizer-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'limrun', + retainOnClose: true, + }); + const recoverExpiredLease = vi.fn(() => new Promise(() => {})); + const expiredProviderLeaseReleaser = createExpiredProviderLeaseReleaser({ + recoverExpiredLease, + recoverableProviderIds: ['limrun'], + stateDir, + }); + + try { + expiredProviderLeaseReleaser.beginShutdown(); + const finalization = finalizeDaemonLeases({ + leaseRegistry, + expiredProviderLeaseReleaser, + timeoutMs: 10, + }); + + await vi.advanceTimersByTimeAsync(10); + await finalization; + + expect(recoverExpiredLease).toHaveBeenCalledWith(lease); + expect(leaseRegistry.listActiveLeases()).toEqual([]); + expect(fs.existsSync(path.join(stateDir, 'expired-provider-leases.json'))).toBe(true); + } finally { + expiredProviderLeaseReleaser.shutdown(); + vi.useRealTimers(); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/server/daemon-session-lease-finalizer.ts b/src/daemon/server/daemon-session-lease-finalizer.ts index c7a93bcfda..3e0721f0b4 100644 --- a/src/daemon/server/daemon-session-lease-finalizer.ts +++ b/src/daemon/server/daemon-session-lease-finalizer.ts @@ -1,19 +1,24 @@ +import type { DeviceLease } from '@agent-device/contracts/device'; 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 { leaseReleaseRequestFor } from '../lease-registry-scope.ts'; import type { SessionState } from '../session-state.ts'; -export async function finalizeDaemonSessionLease(params: { - session: SessionState; +type DaemonLeaseFinalization = { leaseRegistry: LeaseRegistry; expiredProviderLeaseReleaser: ExpiredProviderLeaseReleaser; timeoutMs: number; -}): Promise { - const { session, leaseRegistry, expiredProviderLeaseReleaser, timeoutMs } = params; +}; + +export async function finalizeDaemonSessionLease( + params: DaemonLeaseFinalization & { session: SessionState }, +): Promise { + const { session, leaseRegistry } = params; if (!session.lease) return; - try { - const releaseRequest = leaseScopeToReleaseRequest({ + const activeLease = leaseRegistry.getLease( + leaseScopeToReleaseRequest({ leaseId: session.lease.leaseId, tenantId: session.lease.tenantId, runId: session.lease.runId, @@ -21,23 +26,35 @@ export async function finalizeDaemonSessionLease(params: { leaseProvider: session.lease.leaseProvider, deviceKey: session.lease.deviceKey, clientId: session.lease.clientId, - }); - const activeLease = leaseRegistry.getLease(releaseRequest); - if (!activeLease) return; + }), + ); + if (activeLease) await finalizeDaemonLease(params, activeLease, session.name); +} + +/** Ends every lease no session holds, such as one allocated with `retainOnClose` whose session closed. */ +export async function finalizeDaemonLeases(params: DaemonLeaseFinalization): Promise { + await Promise.all( + params.leaseRegistry.listActiveLeases().map((lease) => finalizeDaemonLease(params, lease)), + ); +} + +async function finalizeDaemonLease( + params: DaemonLeaseFinalization, + lease: DeviceLease, + session?: string, +): Promise { + const { leaseRegistry, expiredProviderLeaseReleaser, timeoutMs } = params; + try { const completed = await releaseWithinTimeout( - expiredProviderLeaseReleaser.release(activeLease), + expiredProviderLeaseReleaser.release(lease), timeoutMs, ); - leaseRegistry.releaseLease(releaseRequest); + leaseRegistry.releaseLease(leaseReleaseRequestFor(lease)); if (!completed) { emitDiagnostic({ level: 'warn', phase: 'daemon_shutdown_session_lease_release_timed_out', - data: { - session: session.name, - leaseId: session.lease.leaseId, - timeoutMs, - }, + data: { session, leaseId: lease.leaseId, timeoutMs }, }); } } catch (error) { @@ -45,8 +62,8 @@ export async function finalizeDaemonSessionLease(params: { level: 'warn', phase: 'daemon_shutdown_session_lease_release_failed', data: { - session: session.name, - leaseId: session.lease.leaseId, + session, + leaseId: lease.leaseId, error: error instanceof Error ? error.message : String(error), }, }); From 3d0c665cea53f937eeb83a9516b006bdebe7dea5 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Sun, 4 Oct 2026 15:11:20 +0200 Subject: [PATCH 3/7] fix(daemon): keep shutdown lease finalization resilient and document shutdown for retained leases --- packages/contracts/src/client-lease.ts | 5 +- .../server/daemon-session-lease-finalizer.ts | 53 +++++++++++-------- website/docs/docs/client-api.md | 2 +- website/docs/docs/remote-proxy.md | 2 +- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index 3a308064ee..db80f177ed 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -32,7 +32,10 @@ export type LeaseAllocateOptions = LeaseOptions & { provider?: string; deviceKey?: string; clientId?: string; - /** Keeps the lease through session `close`; it then ends only through `leases.release` or expiry. */ + /** + * Keeps the lease through session `close`; it then ends only through `leases.release`, expiry, or + * daemon shutdown. Asking for it on a lease the run already holds turns it on for that lease. + */ retainOnClose?: boolean; }; diff --git a/src/daemon/server/daemon-session-lease-finalizer.ts b/src/daemon/server/daemon-session-lease-finalizer.ts index 3e0721f0b4..126a5925f6 100644 --- a/src/daemon/server/daemon-session-lease-finalizer.ts +++ b/src/daemon/server/daemon-session-lease-finalizer.ts @@ -16,18 +16,25 @@ export async function finalizeDaemonSessionLease( params: DaemonLeaseFinalization & { session: SessionState }, ): Promise { const { session, leaseRegistry } = params; - if (!session.lease) return; - const activeLease = leaseRegistry.getLease( - 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 sessionLease = session.lease; + if (!sessionLease) return; + let activeLease: DeviceLease | undefined; + try { + activeLease = leaseRegistry.getLease( + leaseScopeToReleaseRequest({ + leaseId: sessionLease.leaseId, + tenantId: sessionLease.tenantId, + runId: sessionLease.runId, + leaseBackend: sessionLease.leaseBackend, + leaseProvider: sessionLease.leaseProvider, + deviceKey: sessionLease.deviceKey, + clientId: sessionLease.clientId, + }), + ); + } catch (error) { + reportLeaseReleaseFailure(sessionLease.leaseId, session.name, error); + return; + } if (activeLease) await finalizeDaemonLease(params, activeLease, session.name); } @@ -58,18 +65,22 @@ async function finalizeDaemonLease( }); } } catch (error) { - emitDiagnostic({ - level: 'warn', - phase: 'daemon_shutdown_session_lease_release_failed', - data: { - session, - leaseId: lease.leaseId, - error: error instanceof Error ? error.message : String(error), - }, - }); + reportLeaseReleaseFailure(lease.leaseId, session, error); } } +function reportLeaseReleaseFailure( + leaseId: string, + session: string | undefined, + error: unknown, +): void { + emitDiagnostic({ + level: 'warn', + phase: 'daemon_shutdown_session_lease_release_failed', + data: { session, 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); diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 3338331c12..b5c039eac9 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -342,7 +342,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()`. Pass `retainOnClose: true` to `allocate()` when you release the lease yourself; session `close` then leaves it active until `release()` or expiry. +- `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. - `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 d8b299ca6f..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, except a lease allocated with `retainOnClose`, which only `leases.release` or expiry ends; `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. From 918e01b4a0c3954b739a1c6aca19b3a18d44c174 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Sun, 4 Oct 2026 14:25:54 +0200 Subject: [PATCH 4/7] chore(gates): record retainOnClose lease field in the wire-compat ledger --- test/wire-compat/ledger.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index c6cf7cbb49..d01fda9843 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", From 7ecae3165b025e5617a533e5cba317c63c9a6004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 5 Oct 2026 11:26:43 +0200 Subject: [PATCH 5/7] refactor(daemon): end every lease through the one shutdown sweep Session teardown no longer releases its own lease in beforeDelete. finalizeDaemonLeases releases every lease still active after teardown, held by a session or retained through close, with one bounded release path. It now runs in the shutdown diagnostics scope, so its timeout and failure warnings reach daemon.log; the per-session variant emitted them outside any scope, where they were dropped. --- .../__tests__/daemon-runtime-app-log.test.ts | 4 - ...test.ts => daemon-lease-finalizer.test.ts} | 108 ++---------------- src/daemon/server/daemon-lease-finalizer.ts | 60 ++++++++++ .../daemon-runtime-device-claims.test.ts | 10 +- .../daemon-runtime-lifecycle-shutdown.test.ts | 70 ++++++++++++ src/daemon/server/daemon-runtime.ts | 36 +++--- .../server/daemon-session-lease-finalizer.ts | 98 ---------------- 7 files changed, 158 insertions(+), 228 deletions(-) rename src/daemon/server/{daemon-session-lease-finalizer.test.ts => daemon-lease-finalizer.test.ts} (58%) create mode 100644 src/daemon/server/daemon-lease-finalizer.ts delete mode 100644 src/daemon/server/daemon-session-lease-finalizer.ts diff --git a/src/daemon/__tests__/daemon-runtime-app-log.test.ts b/src/daemon/__tests__/daemon-runtime-app-log.test.ts index baad8d7a7a..37710eae23 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({ session, 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/server/daemon-session-lease-finalizer.test.ts b/src/daemon/server/daemon-lease-finalizer.test.ts similarity index 58% rename from src/daemon/server/daemon-session-lease-finalizer.test.ts rename to src/daemon/server/daemon-lease-finalizer.test.ts index a9c1cffdc2..de17febae5 100644 --- a/src/daemon/server/daemon-session-lease-finalizer.test.ts +++ b/src/daemon/server/daemon-lease-finalizer.test.ts @@ -1,17 +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 { - finalizeDaemonLeases, - 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(); @@ -19,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({ @@ -26,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, @@ -65,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({ @@ -83,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, @@ -115,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({ @@ -137,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; @@ -170,52 +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, - }; -} - -test('journals and bounds the release of a lease no session holds', async () => { - vi.useFakeTimers(); - const stateDir = mkdtempForTestSync('agent-device-daemon-lease-finalizer-'); - const leaseRegistry = new LeaseRegistry(); - const lease = leaseRegistry.allocateLease({ - tenantId: 'tenant-a', - runId: 'run-1', - leaseProvider: 'limrun', - retainOnClose: true, - }); - const recoverExpiredLease = vi.fn(() => new Promise(() => {})); - const expiredProviderLeaseReleaser = createExpiredProviderLeaseReleaser({ - recoverExpiredLease, - recoverableProviderIds: ['limrun'], - stateDir, - }); - - try { - expiredProviderLeaseReleaser.beginShutdown(); - const finalization = finalizeDaemonLeases({ - leaseRegistry, - expiredProviderLeaseReleaser, - timeoutMs: 10, - }); - - await vi.advanceTimersByTimeAsync(10); - await finalization; - - expect(recoverExpiredLease).toHaveBeenCalledWith(lease); - expect(leaseRegistry.listActiveLeases()).toEqual([]); - expect(fs.existsSync(path.join(stateDir, 'expired-provider-leases.json'))).toBe(true); - } finally { - expiredProviderLeaseReleaser.shutdown(); - vi.useRealTimers(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); 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 97586767cc..63bc97897c 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({ session, 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({ session, 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 1b39bd2745..546a6ab870 100644 --- a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts +++ b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts @@ -46,10 +46,47 @@ vi.mock('../../provider-device-runtimes.ts', () => ({ createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), })); +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 { startDaemonRuntime } from './daemon-runtime.ts'; afterEach(() => { lifecycleEvents.length = 0; + leaseProbe.registries.length = 0; + leaseProbe.released.length = 0; }); test('daemon shutdown detaches before session teardown and force-finalizes only after gateway resources', async () => { @@ -108,3 +145,36 @@ test('a SIGTERM shutdown gives the handoff a diagnostics scope to write its reas fs.rmSync(stateDir, { recursive: true, force: 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 4c677e4120..992c1f5a7d 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -39,10 +39,7 @@ import type { SessionState } from '../session-state.ts'; import { createDaemonIdleReap } from './daemon-idle-reap.ts'; import { createSessionIdleExpiry } from './daemon-session-idle-expiry.ts'; import { resolveSessionIdleExpiryMs } from '../session-idle-expiry.ts'; -import { - finalizeDaemonLeases, - finalizeDaemonSessionLease, -} from './daemon-session-lease-finalizer.ts'; +import { finalizeDaemonLeases } from './daemon-lease-finalizer.ts'; import { processOwnsActiveDeviceClaim, reconcileOrphanedDeviceClaims, @@ -95,7 +92,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 @@ -141,7 +138,6 @@ export async function teardownDaemonSessionForShutdown(params: { stateDir?: string; stderr: WritableOutput; finalizeApplicationLifecycle?: (session: SessionState) => Promise; - beforeDelete?: (session: SessionState) => Promise; afterSuccessfulTeardown?: (session: SessionState) => Promise; }): Promise { const { @@ -150,7 +146,6 @@ export async function teardownDaemonSessionForShutdown(params: { stateDir, stderr, finalizeApplicationLifecycle, - beforeDelete, afterSuccessfulTeardown, } = params; const sessionName = sessionStore.resolveStoredSessionName(session); @@ -203,7 +198,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(session); - await beforeDelete?.(session); if (teardownSucceeded) await afterSuccessfulTeardown?.(session); sessionStore.delete(sessionName); } @@ -488,14 +482,6 @@ export async function startDaemonRuntime( stateDir: baseDir, runtimeHints: runtimeHintValues(sessionStore.getRuntimeHints(sessionToFinalize.name)), }), - beforeDelete: async (sessionToFinalize) => { - await finalizeDaemonSessionLease({ - session: sessionToFinalize, - leaseRegistry, - expiredProviderLeaseReleaser, - timeoutMs: DAEMON_SESSION_LEASE_RELEASE_TIMEOUT_MS, - }); - }, afterSuccessfulTeardown: shutdownClaimLedger.releaseClaim, }); } finally { @@ -510,7 +496,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 @@ -795,11 +781,17 @@ export async function startDaemonRuntime( } catch {} expiredProviderLeaseReleaser.beginShutdown(); await teardownDaemonSessions(); - await finalizeDaemonLeases({ - leaseRegistry, - expiredProviderLeaseReleaser, - timeoutMs: DAEMON_SESSION_LEASE_RELEASE_TIMEOUT_MS, - }); + 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 126a5925f6..0000000000 --- a/src/daemon/server/daemon-session-lease-finalizer.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { DeviceLease } from '@agent-device/contracts/device'; -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 { leaseReleaseRequestFor } from '../lease-registry-scope.ts'; -import type { SessionState } from '../session-state.ts'; - -type DaemonLeaseFinalization = { - leaseRegistry: LeaseRegistry; - expiredProviderLeaseReleaser: ExpiredProviderLeaseReleaser; - timeoutMs: number; -}; - -export async function finalizeDaemonSessionLease( - params: DaemonLeaseFinalization & { session: SessionState }, -): Promise { - const { session, leaseRegistry } = params; - const sessionLease = session.lease; - if (!sessionLease) return; - let activeLease: DeviceLease | undefined; - try { - activeLease = leaseRegistry.getLease( - leaseScopeToReleaseRequest({ - leaseId: sessionLease.leaseId, - tenantId: sessionLease.tenantId, - runId: sessionLease.runId, - leaseBackend: sessionLease.leaseBackend, - leaseProvider: sessionLease.leaseProvider, - deviceKey: sessionLease.deviceKey, - clientId: sessionLease.clientId, - }), - ); - } catch (error) { - reportLeaseReleaseFailure(sessionLease.leaseId, session.name, error); - return; - } - if (activeLease) await finalizeDaemonLease(params, activeLease, session.name); -} - -/** Ends every lease no session holds, such as one allocated with `retainOnClose` whose session closed. */ -export async function finalizeDaemonLeases(params: DaemonLeaseFinalization): Promise { - await Promise.all( - params.leaseRegistry.listActiveLeases().map((lease) => finalizeDaemonLease(params, lease)), - ); -} - -async function finalizeDaemonLease( - params: DaemonLeaseFinalization, - lease: DeviceLease, - session?: string, -): Promise { - const { leaseRegistry, expiredProviderLeaseReleaser, timeoutMs } = params; - try { - const completed = await releaseWithinTimeout( - expiredProviderLeaseReleaser.release(lease), - timeoutMs, - ); - leaseRegistry.releaseLease(leaseReleaseRequestFor(lease)); - if (!completed) { - emitDiagnostic({ - level: 'warn', - phase: 'daemon_shutdown_session_lease_release_timed_out', - data: { session, leaseId: lease.leaseId, timeoutMs }, - }); - } - } catch (error) { - reportLeaseReleaseFailure(lease.leaseId, session, error); - } -} - -function reportLeaseReleaseFailure( - leaseId: string, - session: string | undefined, - error: unknown, -): void { - emitDiagnostic({ - level: 'warn', - phase: 'daemon_shutdown_session_lease_release_failed', - data: { session, 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); - }, - ); - }); -} From cc8673a84596d3b4dd9867fe21e0f97e91c29928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 5 Oct 2026 11:26:50 +0200 Subject: [PATCH 6/7] fix(daemon): keep an idle daemon alive while a lease is active Idle reap ignored leases, so a retainOnClose lease no session held was released when the daemon reaped itself after five minutes, even with a longer ttlMs. A reap that finds an unexpired lease now waits another idle window; the check also expires leases past their window, so an abandoned lease still ends. --- src/daemon/server/daemon-idle-reap.test.ts | 29 ++++++++++++++++++++++ src/daemon/server/daemon-idle-reap.ts | 9 +++++++ src/daemon/server/daemon-runtime.ts | 1 + 3 files changed, 39 insertions(+) diff --git a/src/daemon/server/daemon-idle-reap.test.ts b/src/daemon/server/daemon-idle-reap.test.ts index 2efee9bd79..b165ae8553 100644 --- a/src/daemon/server/daemon-idle-reap.test.ts +++ b/src/daemon/server/daemon-idle-reap.test.ts @@ -147,6 +147,7 @@ test('idle reap fires after the idle window when nothing is using the daemon', a const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, + hasActiveLeases: () => false, onIdleReap: () => { reaped++; }, @@ -161,6 +162,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; @@ -168,6 +192,7 @@ test('idle reap does not fire while a session is open', async () => { const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, + hasActiveLeases: () => false, onIdleReap: () => { reaped++; }, @@ -196,6 +221,7 @@ test('idle reap does not fire while a recording is active', async () => { const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, + hasActiveLeases: () => false, onIdleReap: () => { reaped++; }, @@ -215,6 +241,7 @@ test('idle reap does not fire while a request is in flight', async () => { const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => inFlightRequestCount, + hasActiveLeases: () => false, onIdleReap: () => { reaped++; }, @@ -237,6 +264,7 @@ test('idle reap is disabled when the window is zero', async () => { const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, + hasActiveLeases: () => false, onIdleReap: () => { reaped++; }, @@ -255,6 +283,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-runtime.ts b/src/daemon/server/daemon-runtime.ts index 992c1f5a7d..c6635ddaeb 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -569,6 +569,7 @@ export async function startDaemonRuntime( const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => inFlightRequestCount, + hasActiveLeases: () => leaseRegistry.listActiveLeases().length > 0, onIdleReap: () => { void shutdown(); }, From c2904782e4805bae38391ac3f4f52cff7c6e9ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 5 Oct 2026 11:26:50 +0200 Subject: [PATCH 7/7] fix(daemon): report retainOnClose to the client and limit it to the lease's own client The client lease now carries retainOnClose, so a caller can tell whether the daemon honored it. Only an allocation from the lease's own client turns it on for a reused run lease. A close that keeps a retained lease emits session_lease_released with released: false, retained: true. ADR 0007 and the client API docs cover the idle-reap and expiry behavior. --- docs/adr/0007-remote-device-leases.md | 6 ++++- packages/contracts/src/client-lease.ts | 5 +++- src/client/lease-client.test.ts | 24 ++++++++++++++++++++ src/client/lease-client.ts | 1 + src/daemon/__tests__/lease-lifecycle.test.ts | 11 +++++++++ src/daemon/__tests__/lease-registry.test.ts | 12 ++++++++++ src/daemon/lease-lifecycle.ts | 14 +++++++++++- src/daemon/lease-registry.ts | 10 ++++---- website/docs/docs/client-api.md | 2 +- 9 files changed, 76 insertions(+), 9 deletions(-) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index 447aeb3de3..70f6c87c10 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -46,7 +46,11 @@ 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`. +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 diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index db80f177ed..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; @@ -34,7 +36,8 @@ export type LeaseAllocateOptions = LeaseOptions & { 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 run already holds turns it on for that lease. + * 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; }; 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__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index d4c9130bcb..f23a2a72ba 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-'); @@ -150,6 +156,11 @@ test('releaseSessionLease leaves a retainOnClose lease and its provider device a 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 () => { diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index 188834d9d3..5b347e5781 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -59,6 +59,18 @@ test('a later allocation asking for retainOnClose turns it on for the reused lea 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/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index 43ce7a6b77..78fc64feda 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -166,7 +166,19 @@ export async function releaseSessionLease(params: { clientId: lease.clientId, }); const activeLease = params.leaseRegistry.getLease(releaseRequest); - if (activeLease?.retainOnClose) return undefined; + if (activeLease?.retainOnClose) { + emitDiagnostic({ + level: 'info', + phase: 'session_lease_released', + data: { + session: params.session.name, + leaseId: lease.leaseId, + released: false, + retained: true, + }, + }); + return undefined; + } const providerData = activeLease ? await params.leaseLifecycleProvider?.release?.(activeLease) : undefined; diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 7b35a3aa31..1e905a498f 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -98,17 +98,17 @@ export class LeaseRegistry { this.runBindings.delete(bindingKey); return undefined; } - const reusedLease: DeviceLease = request.retainOnClose - ? { ...existingLease, retainOnClose: true } - : existingLease; if (existingLease.clientId === request.clientId) { - return this.refreshLease(reusedLease, leaseTtlMs); + return this.refreshLease( + request.retainOnClose ? { ...existingLease, retainOnClose: true } : existingLease, + leaseTtlMs, + ); } if (existingLease.deviceKey) { throw deviceLeaseBusyError(existingLease); } assertLeaseScopeMatch(existingLease, request); - return this.refreshLease(reusedLease, leaseTtlMs); + return this.refreshLease(existingLease, leaseTtlMs); } /** diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index b5c039eac9..b12ea34b44 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -342,7 +342,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()`. Pass `retainOnClose: true` to `allocate()` when you release the lease yourself; session `close` then leaves it active until `release()`, expiry, or daemon shutdown. +- `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()`