From 6e8d5d128c0dffe8b76a4a0196b03a2ca7f283b0 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 12 Oct 2026 11:38:14 +0200 Subject: [PATCH 1/5] feat(cli): release the proxy lease after close In proxy mode the daemon ends a closed session's lease itself, so a host fronting the daemon never saw a release, and the CLI kept the stale lease id: the next open heartbeated it, got LEASE_NOT_FOUND and reallocated. After a successful close the CLI now sends lease_release for its stored proxy lease (any failure is ignored) and drops the lease from its local connection state. Leases the daemon retains on close, host-allocated macos-app leases and non-proxy connections are left alone. --- src/__tests__/cli-close.test.ts | 148 +++++++++++++++++++++++++ src/cli.ts | 4 + src/cli/commands/connection-runtime.ts | 4 +- src/cli/commands/connection.ts | 27 +++++ src/remote/remote-connection-state.ts | 3 + website/docs/docs/remote-proxy.md | 2 +- 6 files changed, 186 insertions(+), 2 deletions(-) diff --git a/src/__tests__/cli-close.test.ts b/src/__tests__/cli-close.test.ts index 31cc99e451..f01546573c 100644 --- a/src/__tests__/cli-close.test.ts +++ b/src/__tests__/cli-close.test.ts @@ -1,7 +1,17 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; +import fs from 'node:fs'; import { runCliCapture, type CapturedCliRun } from './cli-capture.ts'; +import { + connectionWorkspace, + createTestClient, + seedConnectionState, + type StoredConnectionSeed, +} from './remote-connection.fixtures.ts'; +import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; +import { readRemoteConnectionState } from '../remote/remote-connection-state.ts'; +import type { DaemonResponse } from '../daemon-client/daemon-client.ts'; function runCliWithDaemonStartupFailure(argv: string[]): Promise { return runCliCapture(argv, async () => { @@ -85,3 +95,141 @@ test('close --shutdown --json treats daemon startup failure as no-op success', a assert.equal(payload.data.source, 'no-daemon'); assert.equal(result.stderr, ''); }); + +const PROXY_LEASE_ID = 'abc123abc123abc1'; + +function seedProxyConnection(overrides: Partial = {}): { + stateDir: string; + remoteConfigPath: string; +} { + const { stateDir, remoteConfigPath } = connectionWorkspace('agent-device-close-proxy-'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-proxy', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'proxy', + runId: 'proxy-client-1', + leaseId: PROXY_LEASE_ID, + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + clientId: 'client-1', + deviceKey: 'ios:mobile:SIM-001', + platform: 'ios', + ...overrides, + }, + }); + return { stateDir, remoteConfigPath }; +} + +function closeProxySession( + stateDir: string, + releaseResponse: DaemonResponse = { ok: true, data: { released: false } }, +): Promise { + return runCliCapture( + ['close', '--state-dir', stateDir, '--session', 'adc-proxy', '--json'], + async (req) => + req.command === 'lease_release' + ? releaseResponse + : { ok: true, data: { session: 'adc-proxy' } }, + ); +} + +test('proxy close sends lease_release for the stored lease and forgets it locally', async () => { + const { stateDir } = seedProxyConnection(); + const result = await closeProxySession(stateDir); + assert.equal(result.code, null); + assert.deepEqual( + result.calls.map((call) => call.command), + ['close', 'lease_release'], + ); + assert.equal(result.calls[1]?.flags?.leaseId, PROXY_LEASE_ID); + assert.equal(result.calls[1]?.meta?.deviceKey, 'ios:mobile:SIM-001'); + assert.equal(JSON.parse(result.stdout).success, true); + const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); + assert.equal(state?.leaseId, undefined); + assert.equal(state?.leaseProvider, 'proxy'); +}); + +test('proxy close succeeds and forgets the lease when the release finds no lease', async () => { + const { stateDir } = seedProxyConnection(); + const result = await closeProxySession(stateDir, { + ok: false, + error: { + code: 'UNAUTHORIZED', + message: 'Lease is not active', + details: { reason: 'LEASE_NOT_FOUND' }, + }, + }); + assert.equal(result.code, null); + assert.equal(result.stderr, ''); + assert.equal(JSON.parse(result.stdout).success, true); + assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId, undefined); +}); + +test('proxy close keeps a lease the daemon retained on close, as heartbeats reported it', async () => { + const { stateDir, remoteConfigPath } = seedProxyConnection(); + await materializeRemoteConnectionForCommand({ + command: 'snapshot', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + session: 'adc-proxy', + }, + client: createTestClient({ + heartbeat: async (request) => ({ + leaseId: request.leaseId, + tenantId: 'proxy', + runId: 'proxy-client-1', + backend: 'ios-instance', + retainOnClose: true, + }), + }), + }); + const result = await closeProxySession(stateDir); + assert.equal(result.code, null); + assert.deepEqual( + result.calls.map((call) => call.command), + ['close'], + ); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId, + PROXY_LEASE_ID, + ); +}); + +test('close sends no lease_release for host-allocated or non-proxy leases', async () => { + for (const overrides of [ + { leaseBackend: 'macos-app', platform: 'macos', deviceKey: 'com.example.app@4242' }, + { leaseProvider: undefined }, + ] satisfies Partial[]) { + const { stateDir } = seedProxyConnection(overrides); + const result = await closeProxySession(stateDir); + assert.equal(result.code, null); + assert.deepEqual( + result.calls.map((call) => call.command), + ['close'], + ); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId, + PROXY_LEASE_ID, + ); + } +}); + +test('local close sends no lease_release', async () => { + const result = await runCliCapture(['close', '--json'], { + stateDirPrefix: 'agent-device-close-local-', + defaultResponse: { ok: true, data: { session: 'default' } }, + }); + assert.equal(result.code, null); + assert.deepEqual( + result.calls.map((call) => call.command), + ['close'], + ); +}); diff --git a/src/cli.ts b/src/cli.ts index 3fb705fe68..d42cd5c9b8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -185,6 +185,10 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): }), }); await dispatchCliCommand(ctx, client, replayTestReporterRuntime, commandProgress); + if (command === 'close') { + const { releaseProxyLeaseAfterClose } = await import('./cli/commands/connection.ts'); + await releaseProxyLeaseAfterClose(client, ctx.effectiveFlags); + } } catch (error) { await handleRunCliFailure(error, ctx, logTailStopper); } finally { diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 055da012ee..255b374335 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -399,6 +399,7 @@ function leaseStateMatches( return ( state.leaseId === lease.leaseId && state.leaseBackend === leaseBackend && + state.leaseRetainOnClose === lease.retainOnClose && state.deviceKey === (lease.deviceKey ?? state.deviceKey) ); } @@ -414,6 +415,7 @@ function buildMaterializedLeaseState( ...state, leaseId: lease.leaseId, leaseBackend, + leaseRetainOnClose: lease.retainOnClose, ...connection, platform: state.platform ?? flags.platform, target: state.target ?? flags.target, @@ -575,7 +577,7 @@ export async function stopReactDevtoolsCleanup(options: { } /** The host allocated a `macos-app` lease and ends it itself; a tenant never releases it. */ -function isHostAllocatedLease(state: RemoteConnectionState): boolean { +export function isHostAllocatedLease(state: RemoteConnectionState): boolean { return state.leaseBackend === 'macos-app'; } diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 1ce2c5e61e..636b1178ae 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -31,6 +31,7 @@ import { import { connectionPlatformConflict, hasDeferredMetroConfig, + isHostAllocatedLease, releaseRemoteConnectionLease, releasePreviousLease, resolveRequestedLeaseBackend, @@ -41,6 +42,7 @@ import { writeCommandOutput } from './shared.ts'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import type { LeaseBackend } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; +import type { AgentDeviceClient } from '../../agent-device-client.ts'; import type { ClientCommandHandler } from './router-types.ts'; import { resolveConnectContext, type ConnectContext } from './connection-context.ts'; import { @@ -339,6 +341,31 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) return true; }; +/** + * After a proxy `close`, the daemon has already ended the session's lease, but a host fronting the + * daemon only learns that from a release request. The local lease is dropped either way, so the + * next `open` allocates instead of heartbeating a lease that no longer exists. + */ +export async function releaseProxyLeaseAfterClose( + client: AgentDeviceClient, + flags: CliFlags, +): Promise { + if (!flags.remoteConfig) return; + const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); + if (!state?.leaseId || state.leaseRetainOnClose || isHostAllocatedLease(state)) return; + if (connectionProviderCapabilities(state.leaseProvider).leaseKind !== 'proxy') return; + try { + await releaseRemoteConnectionLease(client, state, flags.daemonAuthToken); + } catch { + // The session is already closed and its lease already released or expiring. + } + writeRemoteConnectionState({ + stateDir, + state: { ...state, leaseId: undefined, updatedAt: new Date().toISOString() }, + }); +} + export const connectionCommand: ClientCommandHandler = async ({ positionals, flags }) => { if (positionals[0] !== 'status') { throw new AppError('INVALID_ARGS', 'connection accepts only: status'); diff --git a/src/remote/remote-connection-state.ts b/src/remote/remote-connection-state.ts index 5e5342fe5c..637594fcc3 100644 --- a/src/remote/remote-connection-state.ts +++ b/src/remote/remote-connection-state.ts @@ -41,6 +41,8 @@ export type RemoteConnectionState = { runId: string; leaseId?: string; leaseBackend?: LeaseBackend; + /** The daemon keeps this lease when its session closes; whoever allocated it ends it. */ + leaseRetainOnClose?: true; leaseProvider?: string; deviceKey?: string; clientId?: string; @@ -476,6 +478,7 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState 'deviceKey', 'clientId', ]) && + (record.leaseRetainOnClose === undefined || record.leaseRetainOnClose === true) && isOptionalRemoteConnectionDaemonState(record.daemon) ); } diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 0ba410b89c..50b6d8d037 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -45,7 +45,7 @@ agent-device disconnect `--daemon-auth-token ` works instead of the environment variable, but it authenticates only the command you pass it to. Later commands need the token again through the environment variable, a `daemonAuthToken` entry in your remote config profile, or the flag on each command. -`connect proxy` stores the proxy profile and client identity. `open` leases the device for you, and the lease expires after five minutes without commands. A lease allocated directly over the RPC without `ttlMs` gets 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 its device lease, unless the lease was allocated with `retainOnClose`; only `leases.release`, expiry, or daemon shutdown ends that one. `disconnect` clears local connection state. +`connect proxy` stores the proxy profile and client identity. `open` leases the device for you, and the lease expires after five minutes without commands. A lease allocated directly over the RPC without `ttlMs` gets 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 its device lease, unless the lease was allocated with `retainOnClose`; only `leases.release`, expiry, or daemon shutdown ends that one. After a `close` that ends the lease, the CLI also sends `leases.release` for it, so a gateway in front of the daemon sees the lease end, and the next `open` leases a device again. `disconnect` clears local connection state. Several agents can share one proxy when each follows the same `connect proxy`, `open`, commands, `close`, and `disconnect` flow. A busy device error means another agent holds the device until it closes its session or its lease expires. From 80c995f02becff6cb38bacaa12952a329b5135a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 12:47:56 +0200 Subject: [PATCH 2/5] fix(cli): report a failed proxy lease release after close Replace the bare catch with a warn diagnostic that carries the normalized error code and reason, so auth and network failures are visible. --- src/__tests__/cli-close.test.ts | 19 ++++++++++++++++++- src/cli/commands/connection.ts | 17 ++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/__tests__/cli-close.test.ts b/src/__tests__/cli-close.test.ts index f01546573c..684e5a40e8 100644 --- a/src/__tests__/cli-close.test.ts +++ b/src/__tests__/cli-close.test.ts @@ -1,4 +1,4 @@ -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; import fs from 'node:fs'; @@ -13,6 +13,14 @@ import { materializeRemoteConnectionForCommand } from '../cli/commands/connectio import { readRemoteConnectionState } from '../remote/remote-connection-state.ts'; import type { DaemonResponse } from '../daemon-client/daemon-client.ts'; +const emitDiagnostic = vi.hoisted(() => vi.fn()); + +vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => { + const actual = await importOriginal(); + emitDiagnostic.mockImplementation(actual.emitDiagnostic); + return { ...actual, emitDiagnostic }; +}); + function runCliWithDaemonStartupFailure(argv: string[]): Promise { return runCliCapture(argv, async () => { throw new AppError('COMMAND_FAILED', 'Failed to start daemon', { @@ -167,6 +175,15 @@ test('proxy close succeeds and forgets the lease when the release finds no lease assert.equal(result.stderr, ''); assert.equal(JSON.parse(result.stdout).success, true); assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId, undefined); + const releaseFailure = emitDiagnostic.mock.calls + .map(([event]) => event) + .find((event) => event.phase === 'proxy_lease_release_after_close_failed'); + assert.deepEqual(releaseFailure?.data, { + session: 'adc-proxy', + leaseId: PROXY_LEASE_ID, + code: 'UNAUTHORIZED', + reason: 'LEASE_NOT_FOUND', + }); }); test('proxy close keeps a lease the daemon retained on close, as heartbeats reported it', async () => { diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 636b1178ae..91cb47225b 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -17,7 +17,8 @@ import { type RemoteConnectionState, type RemoteConnectionRequestMetadata, } from '../../remote/remote-connection-state.ts'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { connectProviderNamesForError, connectionProviderCapabilities, @@ -357,8 +358,18 @@ export async function releaseProxyLeaseAfterClose( if (connectionProviderCapabilities(state.leaseProvider).leaseKind !== 'proxy') return; try { await releaseRemoteConnectionLease(client, state, flags.daemonAuthToken); - } catch { - // The session is already closed and its lease already released or expiring. + } catch (error) { + const normalized = normalizeError(error); + emitDiagnostic({ + level: 'warn', + phase: 'proxy_lease_release_after_close_failed', + data: { + session: state.session, + leaseId: state.leaseId, + code: normalized.code, + reason: normalized.details?.reason, + }, + }); } writeRemoteConnectionState({ stateDir, From f7e5cd33191d8823261878fed740878af2ce557d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 14:45:50 +0200 Subject: [PATCH 3/5] feat(daemon): report the lease outcome on the session close reply Session close now says what it did with the session's daemon lease: `lease: { outcome: 'released' | 'retained', leaseId }` or `{ outcome: 'none' }`. A close that fails after the release or retention carries the same field in its error details. The shape is declared once as SessionCloseLeaseOutcome in contracts, and the Node client returns it from sessions.close() and apps.close(). ADR 0007 records that the close reply owns the lease outcome. --- docs/adr/0007-remote-device-leases.md | 7 + packages/contracts/src/client-app.ts | 2 + packages/contracts/src/client-lease.ts | 9 ++ packages/contracts/src/client-session.ts | 2 + packages/contracts/src/facades/client.ts | 1 + packages/contracts/src/lease-scope.ts | 12 ++ .../__tests__/lease-lifecycle.test.ts | 25 +++- .../daemon/src/sessions/lease-lifecycle.ts | 17 ++- .../session-close-lease-outcome.test.ts | 135 ++++++++++++++++++ .../internal/session-close.ts | 41 ++++-- src/agent-device-client.ts | 3 + website/docs/docs/client-api.md | 2 +- 12 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lease-outcome.test.ts diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index 37823e1a04..5a05899e5a 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -53,6 +53,13 @@ an idle daemon alive; other leases, including human-control holds, do not. A retained lease the caller stops heartbeating expires after its `ttlMs`, and idle reap then shuts the daemon down within one more idle window. +The daemon ends a session's lease on `close`, and the close reply owns the outcome. Its `lease` +field is `released` or `retained` with the `leaseId`, or `none` when the session held no daemon +lease. A close that fails after the release or retention carries the same field in its error +details, and a `batch` close step carries it in that step's result. A client never sends a release +after `close`. A client that stores the lease id forgets it on `released`, and a gateway that +relays the close reply reads the lease end from it. + ## Consequences Device contention can fail before platform execution with an explicit diff --git a/packages/contracts/src/client-app.ts b/packages/contracts/src/client-app.ts index 30ee79dde7..fa0d11ee34 100644 --- a/packages/contracts/src/client-app.ts +++ b/packages/contracts/src/client-app.ts @@ -3,6 +3,7 @@ import type { AppsFilter } from './app-inventory.ts'; import type { LaunchConfirmation } from './application-lifecycle-runtime.ts'; import type { JsonObject } from './json.ts'; +import type { SessionCloseLeaseOutcome } from './client-lease.ts'; import type { SessionSurface } from './session-surface.ts'; import type { TargetShutdownResult } from './target-shutdown-contract.ts'; import type { DaemonInstallSource, SessionRuntimeHints } from '@agent-device/kernel/contracts'; @@ -152,6 +153,7 @@ export type AppCloseResult = { * — so a client that requested publication learns where the file landed. */ savedScript?: string; + lease?: SessionCloseLeaseOutcome; identifiers: AgentDeviceIdentifiers; }; diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index 7496ac9158..c4a3652553 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -21,6 +21,15 @@ export type Lease = { expiresAt?: number; }; +/** + * What a session `close` did with the daemon lease the session held. `released`: the lease is no + * longer active. `retained`: a `retainOnClose` lease stays active. `none`: the session held no + * daemon lease. A daemon older than this field omits it. + */ +export type SessionCloseLeaseOutcome = + | { outcome: 'released' | 'retained'; leaseId: string } + | { outcome: 'none' }; + export type LeaseOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { ttlMs?: number; diff --git a/packages/contracts/src/client-session.ts b/packages/contracts/src/client-session.ts index 22e2e91be2..3603654173 100644 --- a/packages/contracts/src/client-session.ts +++ b/packages/contracts/src/client-session.ts @@ -3,6 +3,7 @@ import type { CloudProviderSessionResult } from './cloud-artifacts.ts'; import type { TargetShutdownResult } from './target-shutdown-contract.ts'; import type { AgentDeviceIdentifiers, AgentDeviceRequestOverrides } from './client-connection.ts'; +import type { SessionCloseLeaseOutcome } from './client-lease.ts'; export type SessionCloseResult = { session: string; @@ -14,6 +15,7 @@ export type SessionCloseResult = { * — so a client that requested publication learns where the file landed. */ savedScript?: string; + lease?: SessionCloseLeaseOutcome; identifiers: AgentDeviceIdentifiers; }; diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index f7ec417f78..cd2c1a57a4 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -74,6 +74,7 @@ export type { LeaseAllocateOptions, LeaseOptions, LeaseScopedOptions, + SessionCloseLeaseOutcome, } from '../client-lease.ts'; export type { AudioOptions, diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 3c471ff8ef..79ec55a2dd 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -2,6 +2,7 @@ import type { LeaseBackend } from '@agent-device/kernel/contracts'; import { stripUndefined } from '@agent-device/kernel/record'; import type { CloudProviderProfileFields } from './remote-config-fields.ts'; import type { CommandFlags } from './command-flags.ts'; +import type { SessionCloseLeaseOutcome } from './client-lease.ts'; const PROXY_LEASE_PROVIDER = 'proxy'; @@ -340,6 +341,17 @@ export function isInactiveLeaseError(error: unknown): boolean { ); } +/** Reads the `lease` field of a session close reply; anything else is no outcome. */ +export function readSessionCloseLeaseOutcome(value: unknown): SessionCloseLeaseOutcome | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const { outcome, leaseId } = value as Readonly<{ outcome?: unknown; leaseId?: unknown }>; + if (outcome === 'none') return { outcome }; + if ((outcome === 'released' || outcome === 'retained') && typeof leaseId === 'string') { + return { outcome, leaseId }; + } + return undefined; +} + function leaseScopeToScopedRequest(scope: LeaseScope): LeaseScopedRequestScope { return stripUndefined({ leaseId: scope.leaseId ?? '', diff --git a/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts b/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts index 692416ca6b..e27b6a1b2f 100644 --- a/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts +++ b/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts @@ -118,7 +118,7 @@ test('releaseSessionLease releases with the stored session owner scope', async ( }, }); - const provider = await releaseSessionLease({ + const released = await releaseSessionLease({ session, leaseRegistry, leaseLifecycleProvider: { @@ -127,7 +127,10 @@ test('releaseSessionLease releases with the stored session owner scope', async ( }); expect(leaseRegistry.listActiveLeases()).toHaveLength(0); - expect(provider).toEqual({ provider: 'proxy' }); + expect(released).toEqual({ + lease: { outcome: 'released', leaseId: lease.leaseId }, + providerData: { provider: 'proxy' }, + }); }); test('releaseSessionLease leaves a retainOnClose lease and its provider device alone', async () => { @@ -149,13 +152,13 @@ test('releaseSessionLease leaves a retainOnClose lease and its provider device a }); const release = vi.fn(async () => ({ released: true })); - const provider = await releaseSessionLease({ + const retained = await releaseSessionLease({ session, leaseRegistry, leaseLifecycleProvider: { release }, }); - expect(provider).toBeUndefined(); + expect(retained).toEqual({ lease: { outcome: 'retained', leaseId: lease.leaseId } }); expect(release).not.toHaveBeenCalled(); expect(leaseRegistry.listActiveLeases()).toHaveLength(1); expect(vi.mocked(emitDiagnostic)).toHaveBeenCalledWith({ @@ -165,6 +168,20 @@ test('releaseSessionLease leaves a retainOnClose lease and its provider device a }); }); +test('releaseSessionLease reports no lease for a session that holds none', async () => { + const leaseRegistry = new LeaseRegistry(); + const release = vi.fn(async () => ({ released: true })); + + const outcome = await releaseSessionLease({ + session: makeIosSession('default'), + leaseRegistry, + leaseLifecycleProvider: { release }, + }); + + expect(outcome).toEqual({ lease: { outcome: 'none' } }); + expect(release).not.toHaveBeenCalled(); +}); + test('releaseSessionLease retains provider session ownership for artifact lookup', async () => { const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ diff --git a/packages/daemon/src/sessions/lease-lifecycle.ts b/packages/daemon/src/sessions/lease-lifecycle.ts index 7524b4ff99..b33b6516cc 100644 --- a/packages/daemon/src/sessions/lease-lifecycle.ts +++ b/packages/daemon/src/sessions/lease-lifecycle.ts @@ -1,5 +1,6 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { leaseScopeToReleaseRequest } from '@agent-device/contracts/lease-scope'; +import type { SessionCloseLeaseOutcome } from '@agent-device/contracts/client'; import { clearDeviceClaim } from '../resources/device/device-claims.ts'; import type { LeaseRegistry } from '../resources/lease-registry.ts'; import type { @@ -145,14 +146,17 @@ export function resolveSessionLeaseForRequest(params: { ); } -/** Releases the lease a closing session holds, unless its owner allocated it with `retainOnClose`. */ +/** + * Releases the lease a closing session holds, unless its owner allocated it with `retainOnClose`, + * and reports which of the two happened. + */ export async function releaseSessionLease(params: { session: SessionState; leaseRegistry: LeaseRegistry; leaseLifecycleProvider?: LeaseLifecycleProvider; -}): Promise | undefined> { +}): Promise<{ lease: SessionCloseLeaseOutcome; providerData?: Record }> { const lease = params.session.lease; - if (!lease) return undefined; + if (!lease) return { lease: { outcome: 'none' } }; const releaseRequest = leaseScopeToReleaseRequest({ leaseId: lease.leaseId, tenantId: lease.tenantId, @@ -174,7 +178,7 @@ export async function releaseSessionLease(params: { retained: true, }, }); - return undefined; + return { lease: { outcome: 'retained', leaseId: lease.leaseId } }; } const providerData = activeLease ? await params.leaseLifecycleProvider?.release?.(activeLease) @@ -193,5 +197,8 @@ export async function releaseSessionLease(params: { released: result.released, }, }); - return providerData; + return { + lease: { outcome: 'released', leaseId: lease.leaseId }, + ...(providerData ? { providerData } : {}), + }; } diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lease-outcome.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lease-outcome.test.ts new file mode 100644 index 0000000000..051f99230e --- /dev/null +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lease-outcome.test.ts @@ -0,0 +1,135 @@ +import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; +import { beforeEach, expect, test, vi } from 'vitest'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { SessionStore } from '../../../../core/session-store.ts'; +import type { DaemonRequest, DaemonResponse } from '../../../../core/daemon-request.ts'; +import type { SessionState } from '../../../../core/session-state.ts'; +import { LeaseRegistry } from '../../../../resources/lease-registry.ts'; +import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; +import { handleSessionCommands } from '../../../../__tests__/support/handlers/session-command-harness.ts'; +import { dispatchApplicationLifecycleEffect } from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; + +const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); +const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); +const SESSION_NAME = 'close-lease-outcome'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function leasedSession(leaseRegistry: LeaseRegistry, retainOnClose?: true): SessionState { + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'android-instance', + leaseProvider: 'proxy', + deviceKey: 'android:emulator-5554', + clientId: 'client-a', + ...(retainOnClose ? { retainOnClose } : {}), + }); + return { + ...unleasedSession(), + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }, + }; +} + +function unleasedSession(): SessionState { + return { + name: SESSION_NAME, + device: { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, + }, + createdAt: Date.now(), + actions: [], + }; +} + +async function close( + session: SessionState, + leaseRegistry: LeaseRegistry, + positionals: string[] = [], +): Promise { + const root = mkdtempForTestSync('agent-device-session-close-lease-outcome-'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + sessionStore.publish(SESSION_NAME, session); + return await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { token: 't', session: SESSION_NAME, command: 'close', positionals, flags: {} }, + sessionName: SESSION_NAME, + logPath: path.join(root, 'daemon.log'), + sessionStore, + leaseRegistry, + invoke: noopInvoke, + }); +} + +test('close reports the lease it released', async () => { + const leaseRegistry = new LeaseRegistry(); + const session = leasedSession(leaseRegistry); + + const response = await close(session, leaseRegistry); + + expect(response).toMatchObject({ + ok: true, + data: { lease: { outcome: 'released', leaseId: session.lease?.leaseId } }, + }); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); + +test('close reports a retainOnClose lease as retained and keeps it', async () => { + const leaseRegistry = new LeaseRegistry(); + const session = leasedSession(leaseRegistry, true); + + const response = await close(session, leaseRegistry); + + expect(response).toMatchObject({ + ok: true, + data: { lease: { outcome: 'retained', leaseId: session.lease?.leaseId } }, + }); + expect(leaseRegistry.listActiveLeases()).toHaveLength(1); +}); + +test('close reports no lease for a session without a daemon lease', async () => { + const response = await close(unleasedSession(), new LeaseRegistry()); + + expect(response).toMatchObject({ ok: true, data: { lease: { outcome: 'none' } } }); +}); + +test('a close that fails after releasing its lease reports the release in the error', async () => { + const leaseRegistry = new LeaseRegistry(); + const session = { ...leasedSession(leaseRegistry), appBundleId: 'com.example.app' }; + mockDispatch.mockRejectedValueOnce( + new AppError('DEVICE_UNAVAILABLE', 'platform close failed', { reason: 'device_disconnected' }), + ); + + const failure = await close(session, leaseRegistry, ['com.example.app']).then( + () => undefined, + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(AppError); + expect(failure).toMatchObject({ + code: 'DEVICE_UNAVAILABLE', + details: { + reason: 'device_disconnected', + lease: { outcome: 'released', leaseId: session.lease?.leaseId }, + }, + }); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts index c59c123a47..b9d10b5bba 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts @@ -1,6 +1,7 @@ import type { DaemonPlatformServices } from '../../../core/platform-services.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { AppError, asAppError, normalizeError } from '@agent-device/kernel/errors'; +import type { SessionCloseLeaseOutcome } from '@agent-device/contracts/client'; import type { LeaseLifecycleProvider, TargetShutdownResult } from '@agent-device/contracts/device'; import type { DaemonRequest, DaemonResponse } from '../../../core/daemon-request.ts'; import type { SessionRef, SessionState } from '../../../core/session-state.ts'; @@ -119,9 +120,12 @@ async function releaseProviderLeaseForClose(params: { session: SessionState; leaseRegistry: LeaseRegistry; leaseLifecycleProvider: LeaseLifecycleProvider | undefined; -}): Promise<{ providerData?: Record; response?: DaemonResponse }> { +}): Promise< + | { lease: SessionCloseLeaseOutcome; providerData?: Record } + | { response: DaemonResponse } +> { try { - return { providerData: await releaseSessionLease(params) }; + return await releaseSessionLease(params); } catch (error) { const normalized = normalizeError(error); return { @@ -269,6 +273,7 @@ export async function handleSessionCloseCommands( repair, requestedSaveScript: Boolean(req.flags?.saveScript), shutdownResult: closed.shutdownResult, + lease: closed.lease, providerData: closed.providerData, }); } @@ -277,6 +282,7 @@ type SessionCloseFinalization = | { kind: 'response'; response: DaemonResponse } | { kind: 'closed'; + lease: SessionCloseLeaseOutcome; providerData?: Record; shutdownResult?: TargetShutdownResult; }; @@ -328,7 +334,7 @@ async function runCloseTeardownAndRelease(params: { leaseRegistry, leaseLifecycleProvider, }); - if (leaseRelease.response) return { kind: 'response', response: leaseRelease.response }; + if ('response' in leaseRelease) return { kind: 'response', response: leaseRelease.response }; session = sessionStore.resolveCurrent(ref) ?? session; const cleanupAggregate = closeCleanupError(sessionName, cleanupFailures); const deviceClaimBlockingError = platformCloseError ?? cleanupAggregate; @@ -347,9 +353,21 @@ async function runCloseTeardownAndRelease(params: { await clearDeviceClaim(session.deviceClaim); } sessionStore.retire(ref); - if (deviceClaimBlockingError) throw deviceClaimBlockingError; - if (saveScriptError) throw saveScriptError; - return { kind: 'closed', providerData: leaseRelease.providerData, shutdownResult }; + const closeError = deviceClaimBlockingError ?? saveScriptError; + if (closeError) throw withCloseLeaseOutcome(closeError, leaseRelease.lease); + return { + kind: 'closed', + lease: leaseRelease.lease, + providerData: leaseRelease.providerData, + shutdownResult, + }; +} + +/** A close that fails after its lease was released or retained still reports that outcome. */ +function withCloseLeaseOutcome(error: unknown, lease: SessionCloseLeaseOutcome): unknown { + if (lease.outcome === 'none') return error; + const appError = asAppError(error); + return new AppError(appError.code, appError.message, { ...appError.details, lease }, error); } function buildCloseSuccessResponse(params: { @@ -357,9 +375,10 @@ function buildCloseSuccessResponse(params: { repair: Extract; requestedSaveScript: boolean; shutdownResult: TargetShutdownResult | undefined; + lease: SessionCloseLeaseOutcome; providerData: Record | undefined; }): DaemonResponse { - const { session, repair, requestedSaveScript, shutdownResult, providerData } = params; + const { session, repair, requestedSaveScript, shutdownResult, lease, providerData } = params; if (repair.aborted && requestedSaveScript) { return { ok: false, @@ -367,7 +386,7 @@ function buildCloseSuccessResponse(params: { code: 'COMMAND_FAILED', message: 'The repair was aborted and no script was written.', hint: 'Recovery: replay --from --plan-digest before closing.', - details: { session: session.name }, + details: { session: session.name, ...(lease.outcome === 'none' ? {} : { lease }) }, }, }; } @@ -381,14 +400,14 @@ function buildCloseSuccessResponse(params: { return { ok: true, data: withSuccessText( - { session: session.name, shutdown: shutdownResult, ...savedScript, ...provider }, + { session: session.name, shutdown: shutdownResult, lease, ...savedScript, ...provider }, text, ), }; } return { ok: true, - data: { session: session.name, ...successText(text), ...savedScript, ...provider }, + data: { session: session.name, lease, ...successText(text), ...savedScript, ...provider }, }; } diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index a23449959a..2798c04b90 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -76,6 +76,7 @@ import { asOptionalRecord, readSnapshotKeyboardBandFact } from '@agent-device/ke import { readSnapshotViewportSize } from '@agent-device/kernel/rect'; import { readResponseWarnings } from '@agent-device/kernel/success-text'; import { createLeaseClient } from './client/lease-client.ts'; +import { readSessionCloseLeaseOutcome } from '@agent-device/contracts/lease-scope'; import { normalizeScreenshotCaptureResult } from './client/screenshot-result.ts'; import { createRequestGuard } from './daemon-client/daemon-client-transport.ts'; @@ -218,6 +219,7 @@ export function createAgentDeviceClient( shutdown: normalizeTargetShutdownResult(data.shutdown), provider: asOptionalRecord(data.provider), savedScript: readOptionalString(data, 'savedScript'), + lease: readSessionCloseLeaseOutcome(data.lease), identifiers: { session }, }; } finally { @@ -324,6 +326,7 @@ export function createAgentDeviceClient( closedApp: options.app, shutdown: normalizeTargetShutdownResult(data.shutdown), savedScript: readOptionalString(data, 'savedScript'), + lease: readSessionCloseLeaseOutcome(data.lease), identifiers: { session }, }; }, diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index b5dc409165..df9a818e44 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -247,7 +247,7 @@ Domain client methods: - `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. 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.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. `sessions.close()` and `apps.close()` return `lease`, which says what the close did with the session's lease: `{ outcome: 'released', leaseId }`, `{ outcome: 'retained', leaseId }`, or `{ outcome: 'none' }`. An older daemon omits it. - `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()` From a7dc1d6a6ca3cb75a145abdc49bb5ba0d92ed238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 14:45:50 +0200 Subject: [PATCH 4/5] refactor(cli): forget a proxy lease the daemon close reply released The CLI no longer caches retainOnClose or sends lease_release after close. The daemon already ends the lease on close and reports it, so the CLI transport reads every reply of a remote connection and forgets the stored proxy lease when a close reply, a failed close, or a batch close step reports it released. This covers batch and failed-close routes that the top-level close hook missed. --- src/__tests__/cli-close.test.ts | 189 ++++++++++-------------- src/cli.ts | 34 +++-- src/cli/commands/connection-runtime.ts | 2 - src/cli/commands/connection.ts | 40 +---- src/cli/connection/closed-lease.test.ts | 89 +++++++++++ src/cli/connection/closed-lease.ts | 45 ++++++ src/remote/remote-connection-state.ts | 3 - website/docs/docs/remote-proxy.md | 2 +- 8 files changed, 236 insertions(+), 168 deletions(-) create mode 100644 src/cli/connection/closed-lease.test.ts create mode 100644 src/cli/connection/closed-lease.ts diff --git a/src/__tests__/cli-close.test.ts b/src/__tests__/cli-close.test.ts index 684e5a40e8..4d2a56d790 100644 --- a/src/__tests__/cli-close.test.ts +++ b/src/__tests__/cli-close.test.ts @@ -1,25 +1,15 @@ -import { test, vi } from 'vitest'; +import { test } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; import fs from 'node:fs'; import { runCliCapture, type CapturedCliRun } from './cli-capture.ts'; import { connectionWorkspace, - createTestClient, seedConnectionState, type StoredConnectionSeed, } from './remote-connection.fixtures.ts'; -import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; import { readRemoteConnectionState } from '../remote/remote-connection-state.ts'; -import type { DaemonResponse } from '../daemon-client/daemon-client.ts'; - -const emitDiagnostic = vi.hoisted(() => vi.fn()); - -vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => { - const actual = await importOriginal(); - emitDiagnostic.mockImplementation(actual.emitDiagnostic); - return { ...actual, emitDiagnostic }; -}); +import type { DaemonRequest, DaemonResponse } from '../daemon-client/daemon-client.ts'; function runCliWithDaemonStartupFailure(argv: string[]): Promise { return runCliCapture(argv, async () => { @@ -105,11 +95,9 @@ test('close --shutdown --json treats daemon startup failure as no-op success', a }); const PROXY_LEASE_ID = 'abc123abc123abc1'; +const RELEASED = { outcome: 'released', leaseId: PROXY_LEASE_ID } as const; -function seedProxyConnection(overrides: Partial = {}): { - stateDir: string; - remoteConfigPath: string; -} { +function seedProxyConnection(overrides: Partial = {}): string { const { stateDir, remoteConfigPath } = connectionWorkspace('agent-device-close-proxy-'); fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); seedConnectionState({ @@ -129,124 +117,101 @@ function seedProxyConnection(overrides: Partial = {}): { ...overrides, }, }); - return { stateDir, remoteConfigPath }; + return stateDir; +} + +function storedLeaseId(stateDir: string): string | undefined { + return readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId; } -function closeProxySession( +function runProxyCli( stateDir: string, - releaseResponse: DaemonResponse = { ok: true, data: { released: false } }, + argv: string[], + respond: (req: Omit) => DaemonResponse, ): Promise { return runCliCapture( - ['close', '--state-dir', stateDir, '--session', 'adc-proxy', '--json'], + [...argv, '--state-dir', stateDir, '--session', 'adc-proxy', '--json'], async (req) => - req.command === 'lease_release' - ? releaseResponse - : { ok: true, data: { session: 'adc-proxy' } }, + req.command === 'lease_heartbeat' + ? { + ok: true, + data: { + lease: { + leaseId: PROXY_LEASE_ID, + tenantId: 'proxy', + runId: 'proxy-client-1', + backend: 'ios-instance', + }, + }, + } + : respond(req), ); } -test('proxy close sends lease_release for the stored lease and forgets it locally', async () => { - const { stateDir } = seedProxyConnection(); - const result = await closeProxySession(stateDir); +test('proxy close forgets the stored lease the daemon reports released, and sends no release', async () => { + const stateDir = seedProxyConnection(); + const result = await runProxyCli(stateDir, ['close'], () => ({ + ok: true, + data: { session: 'adc-proxy', lease: RELEASED }, + })); assert.equal(result.code, null); assert.deepEqual( result.calls.map((call) => call.command), - ['close', 'lease_release'], + ['close'], + ); + assert.equal(storedLeaseId(stateDir), undefined); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseProvider, + 'proxy', ); - assert.equal(result.calls[1]?.flags?.leaseId, PROXY_LEASE_ID); - assert.equal(result.calls[1]?.meta?.deviceKey, 'ios:mobile:SIM-001'); - assert.equal(JSON.parse(result.stdout).success, true); - const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); - assert.equal(state?.leaseId, undefined); - assert.equal(state?.leaseProvider, 'proxy'); }); -test('proxy close succeeds and forgets the lease when the release finds no lease', async () => { - const { stateDir } = seedProxyConnection(); - const result = await closeProxySession(stateDir, { +test('a failed proxy close forgets the stored lease the daemon already released', async () => { + const stateDir = seedProxyConnection(); + const result = await runProxyCli(stateDir, ['close'], () => ({ ok: false, error: { - code: 'UNAUTHORIZED', - message: 'Lease is not active', - details: { reason: 'LEASE_NOT_FOUND' }, + code: 'DEVICE_UNAVAILABLE', + message: 'platform close failed', + details: { lease: RELEASED }, }, - }); - assert.equal(result.code, null); - assert.equal(result.stderr, ''); - assert.equal(JSON.parse(result.stdout).success, true); - assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId, undefined); - const releaseFailure = emitDiagnostic.mock.calls - .map(([event]) => event) - .find((event) => event.phase === 'proxy_lease_release_after_close_failed'); - assert.deepEqual(releaseFailure?.data, { - session: 'adc-proxy', - leaseId: PROXY_LEASE_ID, - code: 'UNAUTHORIZED', - reason: 'LEASE_NOT_FOUND', - }); + })); + assert.equal(result.code, 1); + assert.equal(storedLeaseId(stateDir), undefined); }); -test('proxy close keeps a lease the daemon retained on close, as heartbeats reported it', async () => { - const { stateDir, remoteConfigPath } = seedProxyConnection(); - await materializeRemoteConnectionForCommand({ - command: 'snapshot', - flags: { - json: true, - help: false, - version: false, - stateDir, - remoteConfig: remoteConfigPath, - session: 'adc-proxy', +test('a proxy batch forgets the stored lease its close step reports released', async () => { + const closeStep = { step: 1, command: 'close', ok: true, data: { lease: RELEASED } }; + const batchReplies: DaemonResponse[] = [ + { + ok: true, + data: { + total: 1, + executed: 1, + totalDurationMs: 1, + results: [{ ...closeStep, durationMs: 1 }], + }, }, - client: createTestClient({ - heartbeat: async (request) => ({ - leaseId: request.leaseId, - tenantId: 'proxy', - runId: 'proxy-client-1', - backend: 'ios-instance', - retainOnClose: true, - }), - }), - }); - const result = await closeProxySession(stateDir); - assert.equal(result.code, null); - assert.deepEqual( - result.calls.map((call) => call.command), - ['close'], - ); - assert.equal( - readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId, - PROXY_LEASE_ID, - ); -}); - -test('close sends no lease_release for host-allocated or non-proxy leases', async () => { - for (const overrides of [ - { leaseBackend: 'macos-app', platform: 'macos', deviceKey: 'com.example.app@4242' }, - { leaseProvider: undefined }, - ] satisfies Partial[]) { - const { stateDir } = seedProxyConnection(overrides); - const result = await closeProxySession(stateDir); - assert.equal(result.code, null); - assert.deepEqual( - result.calls.map((call) => call.command), - ['close'], + { + ok: false, + error: { + code: 'SESSION_NOT_FOUND', + message: 'Batch failed at step 2 (snapshot): No active session', + details: { step: 2, partialResults: [{ ...closeStep, durationMs: 1 }] }, + }, + }, + ]; + for (const reply of batchReplies) { + const stateDir = seedProxyConnection(); + const result = await runProxyCli( + stateDir, + ['batch', '--steps', '[{"command":"close","input":{}},{"command":"snapshot","input":{}}]'], + () => reply, ); - assert.equal( - readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId, - PROXY_LEASE_ID, + assert.deepEqual( + result.calls.map((call) => call.command).filter((command) => command !== 'lease_heartbeat'), + ['batch'], ); + assert.equal(storedLeaseId(stateDir), undefined); } }); - -test('local close sends no lease_release', async () => { - const result = await runCliCapture(['close', '--json'], { - stateDirPrefix: 'agent-device-close-local-', - defaultResponse: { ok: true, data: { session: 'default' } }, - }); - assert.equal(result.code, null); - assert.deepEqual( - result.calls.map((call) => call.command), - ['close'], - ); -}); diff --git a/src/cli.ts b/src/cli.ts index 65927204ca..198210a3d6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -185,10 +185,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): }), }); await dispatchCliCommand(ctx, client, replayTestReporterRuntime, commandProgress); - if (command === 'close') { - const { releaseProxyLeaseAfterClose } = await import('./cli/commands/connection.ts'); - await releaseProxyLeaseAfterClose(client, ctx.effectiveFlags); - } } catch (error) { await handleRunCliFailure(error, ctx, logTailStopper); } finally { @@ -815,6 +811,28 @@ function hasExplicitMetroRuntimeOverrides(explicitFlagKeys: Set): boole return false; } +type CliDaemonTransportSetup = { + command: string; + flags: CliFlags; + replayTestReporterRuntime?: ReplayTestReporterRuntime; + commandProgress: CommandProgressState; + transport: CliDaemonTransport; +}; + +/** + * A remote connection reads each daemon reply for a session close that released its stored lease. + */ +function createCliDaemonTransport(options: CliDaemonTransportSetup): AgentDeviceDaemonTransport { + const send = createCliProgressTransport(options); + if (!options.flags.remoteConfig) return send; + return async (req, context) => { + const response = await send(req, context); + const { forgetProxyLeaseReleasedByClose } = await import('./cli/connection/closed-lease.ts'); + forgetProxyLeaseReleasedByClose(options.flags, response); + return response; + }; +} + /** * The human CLI renders streamed progress itself: `test` hands its events to the * replay-test reporter, every other command streams `command` lines to stderr @@ -822,13 +840,7 @@ function hasExplicitMetroRuntimeOverrides(explicitFlagKeys: Set): boole * doctor summary does not repeat checks progress already printed). `--json` opts * out of progress entirely, so it installs no sink. */ -function createCliDaemonTransport(options: { - command: string; - flags: CliFlags; - replayTestReporterRuntime?: ReplayTestReporterRuntime; - commandProgress: CommandProgressState; - transport: CliDaemonTransport; -}): AgentDeviceDaemonTransport { +function createCliProgressTransport(options: CliDaemonTransportSetup): AgentDeviceDaemonTransport { const { command, flags, replayTestReporterRuntime, transport } = options; if (flags.json) return createClientDaemonTransport(transport); const onProgress = diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 255b374335..533e06ba51 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -399,7 +399,6 @@ function leaseStateMatches( return ( state.leaseId === lease.leaseId && state.leaseBackend === leaseBackend && - state.leaseRetainOnClose === lease.retainOnClose && state.deviceKey === (lease.deviceKey ?? state.deviceKey) ); } @@ -415,7 +414,6 @@ function buildMaterializedLeaseState( ...state, leaseId: lease.leaseId, leaseBackend, - leaseRetainOnClose: lease.retainOnClose, ...connection, platform: state.platform ?? flags.platform, target: state.target ?? flags.target, diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 91cb47225b..1ce2c5e61e 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -17,8 +17,7 @@ import { type RemoteConnectionState, type RemoteConnectionRequestMetadata, } from '../../remote/remote-connection-state.ts'; -import { AppError, normalizeError } from '@agent-device/kernel/errors'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { AppError } from '@agent-device/kernel/errors'; import { connectProviderNamesForError, connectionProviderCapabilities, @@ -32,7 +31,6 @@ import { import { connectionPlatformConflict, hasDeferredMetroConfig, - isHostAllocatedLease, releaseRemoteConnectionLease, releasePreviousLease, resolveRequestedLeaseBackend, @@ -43,7 +41,6 @@ import { writeCommandOutput } from './shared.ts'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import type { LeaseBackend } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; -import type { AgentDeviceClient } from '../../agent-device-client.ts'; import type { ClientCommandHandler } from './router-types.ts'; import { resolveConnectContext, type ConnectContext } from './connection-context.ts'; import { @@ -342,41 +339,6 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) return true; }; -/** - * After a proxy `close`, the daemon has already ended the session's lease, but a host fronting the - * daemon only learns that from a release request. The local lease is dropped either way, so the - * next `open` allocates instead of heartbeating a lease that no longer exists. - */ -export async function releaseProxyLeaseAfterClose( - client: AgentDeviceClient, - flags: CliFlags, -): Promise { - if (!flags.remoteConfig) return; - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; - const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); - if (!state?.leaseId || state.leaseRetainOnClose || isHostAllocatedLease(state)) return; - if (connectionProviderCapabilities(state.leaseProvider).leaseKind !== 'proxy') return; - try { - await releaseRemoteConnectionLease(client, state, flags.daemonAuthToken); - } catch (error) { - const normalized = normalizeError(error); - emitDiagnostic({ - level: 'warn', - phase: 'proxy_lease_release_after_close_failed', - data: { - session: state.session, - leaseId: state.leaseId, - code: normalized.code, - reason: normalized.details?.reason, - }, - }); - } - writeRemoteConnectionState({ - stateDir, - state: { ...state, leaseId: undefined, updatedAt: new Date().toISOString() }, - }); -} - export const connectionCommand: ClientCommandHandler = async ({ positionals, flags }) => { if (positionals[0] !== 'status') { throw new AppError('INVALID_ARGS', 'connection accepts only: status'); diff --git a/src/cli/connection/closed-lease.test.ts b/src/cli/connection/closed-lease.test.ts new file mode 100644 index 0000000000..5aa61124ee --- /dev/null +++ b/src/cli/connection/closed-lease.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'vitest'; +import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import { + connectionWorkspace, + seedConnectionState, + type StoredConnectionSeed, +} from '../../__tests__/remote-connection.fixtures.ts'; +import { readRemoteConnectionState } from '../../remote/remote-connection-state.ts'; +import { forgetProxyLeaseReleasedByClose } from './closed-lease.ts'; + +const LEASE_ID = 'abc123abc123abc1'; +const RELEASED = { outcome: 'released', leaseId: LEASE_ID } as const; + +function storedLeaseIdAfter( + response: DaemonResponse, + overrides: Partial = {}, +): string | undefined { + const { stateDir, remoteConfigPath } = connectionWorkspace('agent-device-closed-lease-'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-proxy', + remoteConfigPath, + tenant: 'proxy', + runId: 'proxy-client-1', + leaseId: LEASE_ID, + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + platform: 'ios', + ...overrides, + }, + }); + forgetProxyLeaseReleasedByClose( + { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + session: 'adc-proxy', + }, + response, + ); + return readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId; +} + +const closeStep = { step: 1, command: 'close', ok: true, data: { lease: RELEASED }, durationMs: 1 }; + +test('a released lease is forgotten from a close reply, its error, or a batch step', () => { + const replies: DaemonResponse[] = [ + { ok: true, data: { session: 'adc-proxy', lease: RELEASED } }, + { + ok: false, + error: { code: 'COMMAND_FAILED', message: 'failed', details: { lease: RELEASED } }, + }, + { ok: true, data: { total: 1, executed: 1, totalDurationMs: 1, results: [closeStep] } }, + { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'step 2', + details: { partialResults: [closeStep] }, + }, + }, + ]; + for (const reply of replies) assert.equal(storedLeaseIdAfter(reply), undefined); +}); + +test('the stored lease stays unless the reply releases that lease', () => { + const replies: DaemonResponse[] = [ + { ok: true, data: { lease: { outcome: 'retained', leaseId: LEASE_ID } } }, + { ok: true, data: { lease: { outcome: 'none' } } }, + { ok: true, data: { lease: { outcome: 'released', leaseId: 'other-lease' } } }, + { ok: true, data: { lease: { leaseId: LEASE_ID, tenantId: 'proxy', runId: 'run' } } }, + ]; + for (const reply of replies) assert.equal(storedLeaseIdAfter(reply), LEASE_ID); +}); + +test('a released lease the CLI does not own stays stored: host-allocated or non-proxy', () => { + const reply: DaemonResponse = { ok: true, data: { lease: RELEASED } }; + assert.equal( + storedLeaseIdAfter(reply, { leaseBackend: 'macos-app', platform: 'macos' }), + LEASE_ID, + ); + assert.equal(storedLeaseIdAfter(reply, { leaseProvider: undefined }), LEASE_ID); +}); diff --git a/src/cli/connection/closed-lease.ts b/src/cli/connection/closed-lease.ts new file mode 100644 index 0000000000..6f79b02637 --- /dev/null +++ b/src/cli/connection/closed-lease.ts @@ -0,0 +1,45 @@ +import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import { asOptionalRecord } from '@agent-device/kernel/record'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { readSessionCloseLeaseOutcome } from '@agent-device/contracts/lease-scope'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { + readRemoteConnectionState, + writeRemoteConnectionState, +} from '../../remote/remote-connection-state.ts'; +import { isHostAllocatedLease } from '../commands/connection-runtime.ts'; +import { connectionProviderCapabilities } from './provider-policy.ts'; + +/** + * Forgets the stored proxy lease once a daemon reply says a session close released it, so the next + * `open` allocates instead of heartbeating a lease that no longer exists. + */ +export function forgetProxyLeaseReleasedByClose(flags: CliFlags, response: DaemonResponse): void { + if (!flags.remoteConfig) return; + const releasedLeaseIds = closeLeaseFields(response).flatMap((field) => { + const lease = readSessionCloseLeaseOutcome(field); + return lease?.outcome === 'released' ? [lease.leaseId] : []; + }); + if (releasedLeaseIds.length === 0) return; + const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); + if (!state?.leaseId || !releasedLeaseIds.includes(state.leaseId)) return; + if (isHostAllocatedLease(state)) return; + if (connectionProviderCapabilities(state.leaseProvider).leaseKind !== 'proxy') return; + writeRemoteConnectionState({ + stateDir, + state: { ...state, leaseId: undefined, updatedAt: new Date().toISOString() }, + }); +} + +/** + * A close reports its lease on the reply, or in the error details when it failed after the release. + * A batch carries each close step's reply in `results`, or in `partialResults` when a later step + * failed; a batch that failed at the close step carries that step's error details. + */ +function closeLeaseFields(response: DaemonResponse): unknown[] { + const body = response.ok ? response.data : response.error.details; + const steps = response.ok ? body?.results : body?.partialResults; + const stepBodies = Array.isArray(steps) ? steps.map((step) => asOptionalRecord(step)?.data) : []; + return [body, ...stepBodies].map((stepBody) => asOptionalRecord(stepBody)?.lease); +} diff --git a/src/remote/remote-connection-state.ts b/src/remote/remote-connection-state.ts index 637594fcc3..5e5342fe5c 100644 --- a/src/remote/remote-connection-state.ts +++ b/src/remote/remote-connection-state.ts @@ -41,8 +41,6 @@ export type RemoteConnectionState = { runId: string; leaseId?: string; leaseBackend?: LeaseBackend; - /** The daemon keeps this lease when its session closes; whoever allocated it ends it. */ - leaseRetainOnClose?: true; leaseProvider?: string; deviceKey?: string; clientId?: string; @@ -478,7 +476,6 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState 'deviceKey', 'clientId', ]) && - (record.leaseRetainOnClose === undefined || record.leaseRetainOnClose === true) && isOptionalRemoteConnectionDaemonState(record.daemon) ); } diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 50b6d8d037..3421c6bc80 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -45,7 +45,7 @@ agent-device disconnect `--daemon-auth-token ` works instead of the environment variable, but it authenticates only the command you pass it to. Later commands need the token again through the environment variable, a `daemonAuthToken` entry in your remote config profile, or the flag on each command. -`connect proxy` stores the proxy profile and client identity. `open` leases the device for you, and the lease expires after five minutes without commands. A lease allocated directly over the RPC without `ttlMs` gets 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 its device lease, unless the lease was allocated with `retainOnClose`; only `leases.release`, expiry, or daemon shutdown ends that one. After a `close` that ends the lease, the CLI also sends `leases.release` for it, so a gateway in front of the daemon sees the lease end, and the next `open` leases a device again. `disconnect` clears local connection state. +`connect proxy` stores the proxy profile and client identity. `open` leases the device for you, and the lease expires after five minutes without commands. A lease allocated directly over the RPC without `ttlMs` gets 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 its device lease, unless the lease was allocated with `retainOnClose`; only `leases.release`, expiry, or daemon shutdown ends that one. The daemon's `close` reply reports what happened to the lease in its `lease` field: `released`, `retained`, or `none` when the session held no lease. A close that fails after the release reports it in the error details. A gateway in front of the daemon reads the lease end from that reply. When the reply says `released`, the CLI forgets the lease, so the next `open` leases a device again. `disconnect` clears local connection state. Several agents can share one proxy when each follows the same `connect proxy`, `open`, commands, `close`, and `disconnect` flow. A busy device error means another agent holds the device until it closes its session or its lease expires. From e38482f34a35462ef0cbcaf70f6e4c90428393f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 18:06:32 +0200 Subject: [PATCH 5/5] fix(daemon): report ended leases once at response level Every daemon reply to a request that ended a session lease now lists it in endedLeases at response level (data on success, error.details on failure). The outermost request owns the ledger; batch steps, replay and test script closes, and test session cleanup record on it, so proxy replay/test of a script that closes tells the CLI and a relaying gateway the lease ended. The per-close lease field and SessionCloseLeaseOutcome are gone, and the CLI reads only the response-level field. --- docs/adr/0007-remote-device-leases.md | 23 +-- packages/contracts/src/client-app.ts | 2 - packages/contracts/src/client-lease.ts | 12 +- packages/contracts/src/client-session.ts | 2 - packages/contracts/src/facades/client.ts | 2 +- packages/contracts/src/lease-scope.ts | 23 +-- .../__tests__/request-ended-leases.test.ts | 146 ++++++++++++++++++ packages/daemon/src/core/daemon-request.ts | 6 + .../daemon/src/core/request-ended-leases.ts | 40 +++++ packages/daemon/src/server/request-router.ts | 10 +- .../__tests__/lease-lifecycle.test.ts | 14 +- .../__tests__/session-replay-command.test.ts | 68 ++++++++ .../handlers/session-replay-command.ts | 20 ++- .../daemon/src/sessions/lease-lifecycle.ts | 16 +- ...t.ts => session-close-ended-lease.test.ts} | 79 ++++------ .../internal/session-close.ts | 46 ++---- src/__tests__/cli-close.test.ts | 36 +++-- src/agent-device-client.ts | 3 - src/cli.ts | 7 +- src/cli/connection/closed-lease.ts | 45 ------ ...osed-lease.test.ts => ended-lease.test.ts} | 39 ++--- src/cli/connection/ended-lease.ts | 29 ++++ website/docs/docs/client-api.md | 2 +- website/docs/docs/remote-proxy.md | 2 +- 24 files changed, 451 insertions(+), 221 deletions(-) create mode 100644 packages/daemon/src/core/__tests__/request-ended-leases.test.ts create mode 100644 packages/daemon/src/core/request-ended-leases.ts rename packages/daemon/src/sessions/session-lifecycle/internal/__tests__/{session-close-lease-outcome.test.ts => session-close-ended-lease.test.ts} (65%) delete mode 100644 src/cli/connection/closed-lease.ts rename src/cli/connection/{closed-lease.test.ts => ended-lease.test.ts} (61%) create mode 100644 src/cli/connection/ended-lease.ts diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index e312e73c6c..b69a1f181e 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -62,16 +62,19 @@ in-flight work and human-control holds still keep a lease from expiring. Once daemon shutdown begins, no new lease-end teardown starts, and shutdown waits for any that already started before it tears sessions down itself. -The daemon ends a session's lease on `close`, and the close reply owns the outcome. Its `lease` -field is `released` or `retained` with the `leaseId`, or `none` when the session held no daemon -lease. A close that finds its lease already inactive reports `released`. A close that fails after -the release or retention carries the same field in its error details, and a `batch` close step -carries it in that step's result. A client never sends a release after `close`. A client that -stores the lease id forgets it on `released`, and a gateway that relays the close reply reads the -lease end from it. Close and lease-end teardown take the same execution locks, so whichever runs -second finds the session gone: a close after a lease end has no session and no `lease` field, and a -client that still stores that lease id finds it inactive on its next heartbeat and allocates a new -lease under a fresh id. +The daemon ends a session's lease when a request closes that session, unless the lease was +allocated with `retainOnClose`. The reply to that request reports each lease it ended once, at +response level, as `endedLeases: [{ leaseId, outcome: 'released' }]`: in `data` on success, in +`error.details` on failure, including a close that failed after the release. This holds for every +request that can close a session: `close`, a `batch` close step, a `replay` or `test` script's +`close`, and the session cleanup `test` runs after each script. Nested requests record on the +outermost request's ledger, so a step reply never repeats it. A close that finds its lease already +inactive reports it as ended. A client never sends a release after such a request. A client that +stores the lease id forgets it when the reply names it, and a gateway that relays the reply reads +the lease end from it. Close and lease-end teardown take the same execution locks, so whichever +runs second finds the session gone: a close after a lease end has no session and ends no lease, and +a client that still stores that lease id finds it inactive on its next heartbeat and allocates a +new lease under a fresh id. ## Consequences diff --git a/packages/contracts/src/client-app.ts b/packages/contracts/src/client-app.ts index fa0d11ee34..30ee79dde7 100644 --- a/packages/contracts/src/client-app.ts +++ b/packages/contracts/src/client-app.ts @@ -3,7 +3,6 @@ import type { AppsFilter } from './app-inventory.ts'; import type { LaunchConfirmation } from './application-lifecycle-runtime.ts'; import type { JsonObject } from './json.ts'; -import type { SessionCloseLeaseOutcome } from './client-lease.ts'; import type { SessionSurface } from './session-surface.ts'; import type { TargetShutdownResult } from './target-shutdown-contract.ts'; import type { DaemonInstallSource, SessionRuntimeHints } from '@agent-device/kernel/contracts'; @@ -153,7 +152,6 @@ export type AppCloseResult = { * — so a client that requested publication learns where the file landed. */ savedScript?: string; - lease?: SessionCloseLeaseOutcome; identifiers: AgentDeviceIdentifiers; }; diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index 01e535b2a9..57628f61e8 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -22,13 +22,13 @@ export type Lease = { }; /** - * What a session `close` did with the daemon lease the session held. `released`: the lease is no - * longer active. `retained`: a `retainOnClose` lease stays active. `none`: the session held no - * daemon lease. A daemon older than this field omits it. + * A session lease a daemon request ended. A reply to a request that ended leases lists them once in + * `endedLeases` at response level: in `data` on success, in `error.details` on failure. A `close`, + * a `batch` close step, a `replay` or `test` script's close, and `test` session cleanup each end the + * lease they close with, unless it was allocated with `retainOnClose`. A daemon older than this + * field omits it. */ -export type SessionCloseLeaseOutcome = - | { outcome: 'released' | 'retained'; leaseId: string } - | { outcome: 'none' }; +export type EndedLease = { leaseId: string; outcome: 'released' }; export type LeaseOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { diff --git a/packages/contracts/src/client-session.ts b/packages/contracts/src/client-session.ts index 3603654173..22e2e91be2 100644 --- a/packages/contracts/src/client-session.ts +++ b/packages/contracts/src/client-session.ts @@ -3,7 +3,6 @@ import type { CloudProviderSessionResult } from './cloud-artifacts.ts'; import type { TargetShutdownResult } from './target-shutdown-contract.ts'; import type { AgentDeviceIdentifiers, AgentDeviceRequestOverrides } from './client-connection.ts'; -import type { SessionCloseLeaseOutcome } from './client-lease.ts'; export type SessionCloseResult = { session: string; @@ -15,7 +14,6 @@ export type SessionCloseResult = { * — so a client that requested publication learns where the file landed. */ savedScript?: string; - lease?: SessionCloseLeaseOutcome; identifiers: AgentDeviceIdentifiers; }; diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index cd2c1a57a4..f8d5b97d03 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -67,6 +67,7 @@ export type { } from '../client-gesture.ts'; export type { CloudArtifactsOptions, + EndedLease, HumanControlHold, HumanControlHoldOptions, HumanControlHoldScope, @@ -74,7 +75,6 @@ export type { LeaseAllocateOptions, LeaseOptions, LeaseScopedOptions, - SessionCloseLeaseOutcome, } from '../client-lease.ts'; export type { AudioOptions, diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index e5a744709b..579830f6da 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -1,8 +1,8 @@ -import type { LeaseBackend } from '@agent-device/kernel/contracts'; +import type { DaemonResponse, LeaseBackend } from '@agent-device/kernel/contracts'; import { stripUndefined } from '@agent-device/kernel/record'; import type { CloudProviderProfileFields } from './remote-config-fields.ts'; import type { CommandFlags } from './command-flags.ts'; -import type { SessionCloseLeaseOutcome } from './client-lease.ts'; +import type { EndedLease } from './client-lease.ts'; const PROXY_LEASE_PROVIDER = 'proxy'; @@ -344,15 +344,16 @@ export function isInactiveLeaseError(error: unknown): boolean { ); } -/** Reads the `lease` field of a session close reply; anything else is no outcome. */ -export function readSessionCloseLeaseOutcome(value: unknown): SessionCloseLeaseOutcome | undefined { - if (typeof value !== 'object' || value === null) return undefined; - const { outcome, leaseId } = value as Readonly<{ outcome?: unknown; leaseId?: unknown }>; - if (outcome === 'none') return { outcome }; - if ((outcome === 'released' || outcome === 'retained') && typeof leaseId === 'string') { - return { outcome, leaseId }; - } - return undefined; +/** The leases a daemon reply says its request ended; a malformed entry is no ended lease. */ +export function readEndedLeases(response: DaemonResponse): EndedLease[] { + const body = response.ok ? response.data : response.error.details; + const entries = body?.endedLeases; + if (!Array.isArray(entries)) return []; + return entries.flatMap((entry: unknown) => { + if (typeof entry !== 'object' || entry === null) return []; + const { leaseId, outcome } = entry as Readonly<{ leaseId?: unknown; outcome?: unknown }>; + return typeof leaseId === 'string' && outcome === 'released' ? [{ leaseId, outcome }] : []; + }); } function leaseScopeToScopedRequest(scope: LeaseScope): LeaseScopedRequestScope { diff --git a/packages/daemon/src/core/__tests__/request-ended-leases.test.ts b/packages/daemon/src/core/__tests__/request-ended-leases.test.ts new file mode 100644 index 0000000000..15fe160c70 --- /dev/null +++ b/packages/daemon/src/core/__tests__/request-ended-leases.test.ts @@ -0,0 +1,146 @@ +import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { replayScriptSourceBundleFor } from '@agent-device/replay-port/replay-script-source-fixtures'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { makeIosSession } from '../../__tests__/support/session-factories.ts'; +import { + createRequestHandler, + lifecycleDeviceRuntimeGateway, +} from '../../__tests__/support/test-device-runtime-gateway.ts'; +import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import type { DaemonRequest } from '../daemon-request.ts'; +import { + type EndedLeaseLedger, + onDelegatingLedger, + recordEndedLease, + reportEndedLeases, +} from '../request-ended-leases.ts'; + +const SESSION = 'default'; + +function leasedDaemon() { + const root = mkdtempForTestSync('agent-device-ended-leases-'); + const sessionStore = makeSessionStore('agent-device-ended-leases-store-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + }); + sessionStore.publish( + SESSION, + makeIosSession(SESSION, { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseProvider: lease.leaseProvider, + clientId: lease.clientId, + deviceKey: lease.deviceKey, + }, + }), + ); + const handler = createRequestHandler({ + createCommandSurface: fakeCreateCommandSurface, + logPath: path.join(root, 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry, + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + trackDownloadableArtifact: () => 'artifact-id', + }); + return { + root, + handler, + leaseRegistry, + endedLeases: [{ leaseId: lease.leaseId, outcome: 'released' }], + }; +} + +function request(command: string, positionals: string[] = [], flags = {}): DaemonRequest { + return { token: 'test-token', session: SESSION, command, positionals, flags }; +} + +test('a close reports the lease it ended once, at response level', async () => { + const { handler, leaseRegistry, endedLeases } = leasedDaemon(); + + const response = await handler(request('close')); + + expect(response).toMatchObject({ ok: true, data: { endedLeases } }); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); + +test('a batch reports the lease its close step ended on the batch reply only', async () => { + const { handler, endedLeases } = leasedDaemon(); + + const response = await handler( + request('batch', [], { batchSteps: [{ command: 'close', positionals: [], flags: {} }] }), + ); + + expect(response).toMatchObject({ + ok: true, + data: { endedLeases, results: [{ command: 'close', ok: true }] }, + }); + expect(response).not.toMatchObject({ data: { results: [{ data: { endedLeases } }] } }); +}); + +test('a batch that fails after its close step reports the ended lease in the error details', async () => { + const { handler, endedLeases } = leasedDaemon(); + + const response = await handler( + request('batch', [], { + batchSteps: [ + { command: 'close', positionals: [], flags: {} }, + { command: 'snapshot', positionals: [], flags: {} }, + ], + }), + ); + + expect(response).toMatchObject({ ok: false, error: { details: { endedLeases } } }); +}); + +test('a request that ended no lease replies without endedLeases', async () => { + const response = await reportEndedLeases(request('snapshot'), async () => ({ + ok: true, + data: {}, + })); + + expect(response).toEqual({ ok: true, data: {} }); +}); + +test('a nested request records on its delegating ledger and reports nothing itself', async () => { + const ledger: EndedLeaseLedger = []; + const nested = onDelegatingLedger(request('close'), { + ...request('batch'), + internal: { endedLeases: ledger }, + }); + + const response = await reportEndedLeases(nested, async (req) => { + recordEndedLease(req, 'lease-1'); + return { ok: true, data: {} }; + }); + + expect(response).toEqual({ ok: true, data: {} }); + expect(ledger).toEqual([{ leaseId: 'lease-1', outcome: 'released' }]); +}); + +test('a replay reports the lease its script close ended', async () => { + const { root, handler, endedLeases } = leasedDaemon(); + const scriptPath = path.join(root, 'close.ad'); + fs.writeFileSync(scriptPath, 'close\n'); + + const response = await handler( + request('replay', [scriptPath], { + replayScriptSource: replayScriptSourceBundleFor(scriptPath), + }), + ); + + expect(response).toMatchObject({ ok: true, data: { endedLeases } }); +}); diff --git a/packages/daemon/src/core/daemon-request.ts b/packages/daemon/src/core/daemon-request.ts index bd9063528f..735f1ab478 100644 --- a/packages/daemon/src/core/daemon-request.ts +++ b/packages/daemon/src/core/daemon-request.ts @@ -9,6 +9,7 @@ import type { } from '@agent-device/kernel/contracts'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { RequestDispatchLedger } from './request-dispatch-ledger.ts'; +import type { EndedLeaseLedger } from './request-ended-leases.ts'; /** * The daemon's own request and response vocabulary: the wire shape from `@agent-device/contracts/command` (`DaemonWireRequest`) @@ -60,6 +61,11 @@ type DaemonRequestInternal = ReplayDispatchOptions & { * delegated it so those mutations count toward that request's disclosure too. */ dispatchLedger?: RequestDispatchLedger; + /** + * The leases the outermost request and its nested requests ended. Only the request that created + * it reports it, once, on its reply. + */ + endedLeases?: EndedLeaseLedger; }; /** diff --git a/packages/daemon/src/core/request-ended-leases.ts b/packages/daemon/src/core/request-ended-leases.ts new file mode 100644 index 0000000000..6e4747c3e6 --- /dev/null +++ b/packages/daemon/src/core/request-ended-leases.ts @@ -0,0 +1,40 @@ +import type { EndedLease } from '@agent-device/contracts/client'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from './daemon-request.ts'; + +/** The session leases one request ended, its nested requests' included. */ +export type EndedLeaseLedger = EndedLease[]; + +/** Records a lease `req` ended on the ledger of the outermost request it runs under. */ +export function recordEndedLease(req: DaemonRequest, leaseId: string): void { + req.internal?.endedLeases?.push({ leaseId, outcome: 'released' }); +} + +/** + * Runs `handle` for an outermost request on a ledger of its own and reports the leases it ended on + * its reply. A nested request already carries its delegating request's ledger and reports nothing. + */ +export async function reportEndedLeases( + req: DaemonRequest, + handle: (req: DaemonRequest) => Promise, +): Promise { + if (req.internal?.endedLeases) return await handle(req); + const endedLeases: EndedLeaseLedger = []; + const response = await handle({ ...req, internal: { ...req.internal, endedLeases } }); + if (endedLeases.length === 0) return response; + if (response.ok) return { ok: true, data: { ...response.data, endedLeases } }; + return { + ok: false, + error: { ...response.error, details: { ...response.error.details, endedLeases } }, + }; +} + +/** `req` recording its ended leases on the ledger of `parent`, the request that delegated it. */ +export function onDelegatingLedger(req: DaemonRequest, parent: DaemonRequest): DaemonRequest { + const endedLeases = parent.internal?.endedLeases; + return endedLeases ? { ...req, internal: { ...req.internal, endedLeases } } : req; +} + +/** Each nested request `invoke` runs records its ended leases on the ledger of `parent`. */ +export function handDownEndedLeases(invoke: DaemonInvokeFn, parent: DaemonRequest): DaemonInvokeFn { + return async (req) => await invoke(onDelegatingLedger(req, parent)); +} diff --git a/packages/daemon/src/server/request-router.ts b/packages/daemon/src/server/request-router.ts index 43af03b157..3d8441c89a 100644 --- a/packages/daemon/src/server/request-router.ts +++ b/packages/daemon/src/server/request-router.ts @@ -92,6 +92,7 @@ import { refusedBeforeDispatch, } from '../execution/request-dispatch-disclosure.ts'; import { recordNestedRequests } from '../core/request-dispatch-ledger.ts'; +import { handDownEndedLeases, reportEndedLeases } from '../core/request-ended-leases.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; import type { PlatformResourceCleanup } from '../core/platform-resource-cleanup.ts'; @@ -213,6 +214,10 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { : deps.deviceInventoryGateways; async function handleRequest(req: DaemonRequest): Promise { + return await reportEndedLeases(req, handleRequestOnLedger); + } + + async function handleRequestOnLedger(req: DaemonRequest): Promise { const start = Date.now(); const debug = Boolean(req.meta?.debug || req.flags?.verbose); return await withDiagnosticsScope( @@ -385,7 +390,10 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { cloudArtifactProvider, endSessionsForLease, providerAppCatalog, - invoke: recordNestedRequests(handleRequest, dispatchLedger), + invoke: handDownEndedLeases( + recordNestedRequests(handleRequest, dispatchLedger), + lockedScope.req, + ), invokeReplayAction: allowReplayActions ? recordNestedRequests( createReplayScopedActionInvoker(lockedScope, providerScope), diff --git a/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts b/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts index e27b6a1b2f..ef2e3b8d2d 100644 --- a/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts +++ b/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts @@ -127,10 +127,7 @@ test('releaseSessionLease releases with the stored session owner scope', async ( }); expect(leaseRegistry.listActiveLeases()).toHaveLength(0); - expect(released).toEqual({ - lease: { outcome: 'released', leaseId: lease.leaseId }, - providerData: { provider: 'proxy' }, - }); + expect(released).toEqual({ endedLeaseId: lease.leaseId, providerData: { provider: 'proxy' } }); }); test('releaseSessionLease leaves a retainOnClose lease and its provider device alone', async () => { @@ -158,7 +155,7 @@ test('releaseSessionLease leaves a retainOnClose lease and its provider device a leaseLifecycleProvider: { release }, }); - expect(retained).toEqual({ lease: { outcome: 'retained', leaseId: lease.leaseId } }); + expect(retained).toEqual({}); expect(release).not.toHaveBeenCalled(); expect(leaseRegistry.listActiveLeases()).toHaveLength(1); expect(vi.mocked(emitDiagnostic)).toHaveBeenCalledWith({ @@ -168,17 +165,16 @@ test('releaseSessionLease leaves a retainOnClose lease and its provider device a }); }); -test('releaseSessionLease reports no lease for a session that holds none', async () => { - const leaseRegistry = new LeaseRegistry(); +test('releaseSessionLease ends no lease for a session that holds none', async () => { const release = vi.fn(async () => ({ released: true })); const outcome = await releaseSessionLease({ session: makeIosSession('default'), - leaseRegistry, + leaseRegistry: new LeaseRegistry(), leaseLifecycleProvider: { release }, }); - expect(outcome).toEqual({ lease: { outcome: 'none' } }); + expect(outcome).toEqual({}); expect(release).not.toHaveBeenCalled(); }); diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-replay-command.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-replay-command.test.ts index 24af32e8ec..8aba235f06 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-replay-command.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-replay-command.test.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; import { LeaseRegistry } from '../../../resources/lease-registry.ts'; @@ -13,6 +14,13 @@ import { daemonPlatformServicesFixture } from '../../../__tests__/support/platfo import { makeSessionStore } from '../../../__tests__/support/handlers/session-test-harness.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; +import { withClientReplayScriptSources } from '@agent-device/replay-port/replay-script-source-fixtures'; +import { makeIosSession } from '../../../__tests__/support/session-factories.ts'; +import { + mockBindDeviceRuntime, + mockInspectDeviceRuntimeFacts, +} from '../../../__tests__/support/handlers/session-command-harness.ts'; +import type { EndedLeaseLedger } from '../../../core/request-ended-leases.ts'; test('replay test handler threads the complete video owner into the application command', async () => { const root = mkdtempForTestSync('agent-device-replay-handler-video-'); @@ -55,3 +63,63 @@ test('replay test handler threads the complete video owner into the application expect(response.error.code).toBe('INVALID_ARGS'); expect(response.error.message).not.toContain('Screen-recording runtime is not configured'); }); + +test('replay test session cleanup reports the lease it ended on the test request', async () => { + const root = mkdtempForTestSync('agent-device-replay-handler-ended-lease-'); + const scriptPath = path.join(root, 'opens.ad'); + fs.writeFileSync(scriptPath, 'context platform=ios\nopen "com.example.app"\n'); + const sessionStore = makeSessionStore(); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + const endedLeases: EndedLeaseLedger = []; + const wireReq = await withClientReplayScriptSources({ + token: 'token', + session: 'default', + command: 'test', + positionals: [scriptPath], + flags: {}, + meta: { cwd: root, requestId: 'replay-handler-ended-lease' }, + }); + const params: SessionCommandParams = { + createCommandSurface: fakeCreateCommandSurface, + req: { ...wireReq, internal: { endedLeases } }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + platformServices: daemonPlatformServicesFixture(), + leaseRegistry, + invoke: async (nestedReq) => { + if (nestedReq.command === 'open' && nestedReq.session) { + sessionStore.publish( + nestedReq.session, + makeIosSession(nestedReq.session, { + lease: { leaseId: lease.leaseId, tenantId: lease.tenantId, runId: lease.runId }, + }), + ); + } + return { ok: true, data: {} }; + }, + inspectFacts: mockInspectDeviceRuntimeFacts, + bindDevice: mockBindDeviceRuntime, + bindExactDevice: unavailableBindExactDevice, + screenRecordingAdmissionLedger: createScreenRecordingAdmissionLedger(), + requestScope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + retainDeviceExecutionLock: async () => {}, + throwIfCanceled: () => {}, + reconcileOrphanedDeviceClaim: async () => ({ + status: 'retained' as const, + reason: 'handler-test' as const, + }), + platformResourceCleanup: platformResourceCleanupFixture(), + }; + + const response = await handleReplayTestCommand(params); + + expect(response).toMatchObject({ ok: true }); + expect(endedLeases).toEqual([{ leaseId: lease.leaseId, outcome: 'released' }]); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); diff --git a/packages/daemon/src/sessions/handlers/session-replay-command.ts b/packages/daemon/src/sessions/handlers/session-replay-command.ts index e2c0e022d4..12833f230a 100644 --- a/packages/daemon/src/sessions/handlers/session-replay-command.ts +++ b/packages/daemon/src/sessions/handlers/session-replay-command.ts @@ -27,6 +27,7 @@ import { resolveSessionScope } from '../session-routing.ts'; import { resolveEffectiveOpenRuntimeHints } from '../session-runtime.ts'; import { captureSnapshot } from '../../core/snapshot-capture.ts'; import { handleSessionCloseCommands } from '../session-lifecycle/index.ts'; +import { onDelegatingLedger } from '../../core/request-ended-leases.ts'; import { createReplayTestVideoOwner } from './session-replay-video-owner.ts'; import type { SessionCommandHandler } from './session-command-input.ts'; @@ -191,14 +192,17 @@ async function closeReplayTestSession(params: ReplayTestSessionCleanupParams): P const { req, sessionName, logPath, sessionStore } = params; if (!sessionStore.get(sessionName)) return; const closeResponse = await handleSessionCloseCommands({ - req: { - token: req.token, - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - meta: req.meta, - }, + req: onDelegatingLedger( + { + token: req.token, + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + meta: req.meta, + }, + req, + ), sessionName, logPath, sessionStore, diff --git a/packages/daemon/src/sessions/lease-lifecycle.ts b/packages/daemon/src/sessions/lease-lifecycle.ts index e3f61d47b7..91d734e336 100644 --- a/packages/daemon/src/sessions/lease-lifecycle.ts +++ b/packages/daemon/src/sessions/lease-lifecycle.ts @@ -1,6 +1,5 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { leaseScopeToReleaseRequest } from '@agent-device/contracts/lease-scope'; -import type { SessionCloseLeaseOutcome } from '@agent-device/contracts/client'; import { clearDeviceClaim } from '../resources/device/device-claims.ts'; import type { LeaseRegistry } from '../resources/lease-registry.ts'; import type { @@ -154,16 +153,16 @@ export function resolveSessionLeaseForRequest(params: { } /** - * Releases the lease a closing session holds, unless its owner allocated it with `retainOnClose`, - * and reports which of the two happened. + * Releases the lease a closing session holds, unless its owner allocated it with `retainOnClose`. + * `endedLeaseId` names the lease the close ended, including one that was already inactive. */ export async function releaseSessionLease(params: { session: SessionState; leaseRegistry: LeaseRegistry; leaseLifecycleProvider?: LeaseLifecycleProvider; -}): Promise<{ lease: SessionCloseLeaseOutcome; providerData?: Record }> { +}): Promise<{ endedLeaseId?: string; providerData?: Record }> { const lease = params.session.lease; - if (!lease) return { lease: { outcome: 'none' } }; + if (!lease) return {}; const releaseRequest = leaseScopeToReleaseRequest({ leaseId: lease.leaseId, tenantId: lease.tenantId, @@ -185,7 +184,7 @@ export async function releaseSessionLease(params: { retained: true, }, }); - return { lease: { outcome: 'retained', leaseId: lease.leaseId } }; + return {}; } const providerData = activeLease ? await params.leaseLifecycleProvider?.release?.(activeLease) @@ -204,8 +203,5 @@ export async function releaseSessionLease(params: { released: result.released, }, }); - return { - lease: { outcome: 'released', leaseId: lease.leaseId }, - ...(providerData ? { providerData } : {}), - }; + return { endedLeaseId: lease.leaseId, ...(providerData ? { providerData } : {}) }; } diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lease-outcome.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-ended-lease.test.ts similarity index 65% rename from packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lease-outcome.test.ts rename to packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-ended-lease.test.ts index d3da174025..b10e1ab0a7 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lease-outcome.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-ended-lease.test.ts @@ -10,10 +10,11 @@ import { LeaseRegistry } from '../../../../resources/lease-registry.ts'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; import { handleSessionCommands } from '../../../../__tests__/support/handlers/session-command-harness.ts'; import { dispatchApplicationLifecycleEffect } from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; +import type { EndedLeaseLedger } from '../../../../core/request-ended-leases.ts'; const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); -const SESSION_NAME = 'close-lease-outcome'; +const SESSION_NAME = 'close-ended-lease'; beforeEach(() => { vi.clearAllMocks(); @@ -59,40 +60,47 @@ function unleasedSession(): SessionState { }; } -async function close( +function close( session: SessionState, leaseRegistry: LeaseRegistry, positionals: string[] = [], -): Promise { - const root = mkdtempForTestSync('agent-device-session-close-lease-outcome-'); +): { response: Promise; endedLeases: EndedLeaseLedger } { + const root = mkdtempForTestSync('agent-device-session-close-ended-lease-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); sessionStore.publish(SESSION_NAME, session); - return await handleSessionCommands({ + const endedLeases: EndedLeaseLedger = []; + const response = handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), - req: { token: 't', session: SESSION_NAME, command: 'close', positionals, flags: {} }, + req: { + token: 't', + session: SESSION_NAME, + command: 'close', + positionals, + flags: {}, + internal: { endedLeases }, + }, sessionName: SESSION_NAME, logPath: path.join(root, 'daemon.log'), sessionStore, leaseRegistry, invoke: noopInvoke, }); + return { response, endedLeases }; } -test('close reports the lease it released', async () => { +test('close records the lease it released on the request ledger', async () => { const leaseRegistry = new LeaseRegistry(); const session = leasedSession(leaseRegistry); - const response = await close(session, leaseRegistry); + const { response, endedLeases } = close(session, leaseRegistry); - expect(response).toMatchObject({ - ok: true, - data: { lease: { outcome: 'released', leaseId: session.lease?.leaseId } }, - }); + expect(await response).toMatchObject({ ok: true }); + expect(endedLeases).toEqual([{ leaseId: session.lease?.leaseId, outcome: 'released' }]); expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); -test('close reports a lease released before its lease-end teardown reached the session as released', async () => { +test('close records a lease released before its lease-end teardown reached the session', async () => { const leaseRegistry = new LeaseRegistry(); const session = leasedSession(leaseRegistry); const lease = session.lease!; @@ -107,52 +115,33 @@ test('close reports a lease released before its lease-end teardown reached the s }); expect(released).toBe(true); - const response = await close(session, leaseRegistry); + const { response, endedLeases } = close(session, leaseRegistry); - expect(response).toMatchObject({ - ok: true, - data: { lease: { outcome: 'released', leaseId: lease.leaseId } }, - }); + expect(await response).toMatchObject({ ok: true }); + expect(endedLeases).toEqual([{ leaseId: lease.leaseId, outcome: 'released' }]); }); -test('close reports a retainOnClose lease as retained and keeps it', async () => { +test('close records no ended lease for a retainOnClose lease or a session without one', async () => { const leaseRegistry = new LeaseRegistry(); - const session = leasedSession(leaseRegistry, true); + for (const session of [leasedSession(leaseRegistry, true), unleasedSession()]) { + const { response, endedLeases } = close(session, leaseRegistry); - const response = await close(session, leaseRegistry); - - expect(response).toMatchObject({ - ok: true, - data: { lease: { outcome: 'retained', leaseId: session.lease?.leaseId } }, - }); + expect(await response).toMatchObject({ ok: true }); + expect(endedLeases).toEqual([]); + } expect(leaseRegistry.listActiveLeases()).toHaveLength(1); }); -test('close reports no lease for a session without a daemon lease', async () => { - const response = await close(unleasedSession(), new LeaseRegistry()); - - expect(response).toMatchObject({ ok: true, data: { lease: { outcome: 'none' } } }); -}); - -test('a close that fails after releasing its lease reports the release in the error', async () => { +test('a close that fails after releasing its lease still records the release', async () => { const leaseRegistry = new LeaseRegistry(); const session = { ...leasedSession(leaseRegistry), appBundleId: 'com.example.app' }; mockDispatch.mockRejectedValueOnce( new AppError('DEVICE_UNAVAILABLE', 'platform close failed', { reason: 'device_disconnected' }), ); - const failure = await close(session, leaseRegistry, ['com.example.app']).then( - () => undefined, - (error: unknown) => error, - ); + const { response, endedLeases } = close(session, leaseRegistry, ['com.example.app']); - expect(failure).toBeInstanceOf(AppError); - expect(failure).toMatchObject({ - code: 'DEVICE_UNAVAILABLE', - details: { - reason: 'device_disconnected', - lease: { outcome: 'released', leaseId: session.lease?.leaseId }, - }, - }); + await expect(response).rejects.toMatchObject({ code: 'DEVICE_UNAVAILABLE' }); + expect(endedLeases).toEqual([{ leaseId: session.lease?.leaseId, outcome: 'released' }]); expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts index b9d10b5bba..bf97d646b7 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts @@ -1,7 +1,6 @@ import type { DaemonPlatformServices } from '../../../core/platform-services.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { AppError, asAppError, normalizeError } from '@agent-device/kernel/errors'; -import type { SessionCloseLeaseOutcome } from '@agent-device/contracts/client'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; import type { LeaseLifecycleProvider, TargetShutdownResult } from '@agent-device/contracts/device'; import type { DaemonRequest, DaemonResponse } from '../../../core/daemon-request.ts'; import type { SessionRef, SessionState } from '../../../core/session-state.ts'; @@ -11,6 +10,7 @@ import { resolveCommandDevice } from '../../../resources/session-device-resoluti import { expireRefFrame } from '../../../core/ref-frame.ts'; import type { LeaseRegistry } from '../../../resources/lease-registry.ts'; import { releaseSessionLease } from '../../lease-lifecycle.ts'; +import { recordEndedLease } from '../../../core/request-ended-leases.ts'; import { hasRepairPlatformCloseReceipt, isRepairArmedSession, @@ -117,15 +117,15 @@ async function prepareRepairClose(params: { } async function releaseProviderLeaseForClose(params: { + req: DaemonRequest; session: SessionState; leaseRegistry: LeaseRegistry; leaseLifecycleProvider: LeaseLifecycleProvider | undefined; -}): Promise< - | { lease: SessionCloseLeaseOutcome; providerData?: Record } - | { response: DaemonResponse } -> { +}): Promise<{ providerData?: Record; response?: DaemonResponse }> { try { - return await releaseSessionLease(params); + const { endedLeaseId, providerData } = await releaseSessionLease(params); + if (endedLeaseId) recordEndedLease(params.req, endedLeaseId); + return { providerData }; } catch (error) { const normalized = normalizeError(error); return { @@ -273,7 +273,6 @@ export async function handleSessionCloseCommands( repair, requestedSaveScript: Boolean(req.flags?.saveScript), shutdownResult: closed.shutdownResult, - lease: closed.lease, providerData: closed.providerData, }); } @@ -282,7 +281,6 @@ type SessionCloseFinalization = | { kind: 'response'; response: DaemonResponse } | { kind: 'closed'; - lease: SessionCloseLeaseOutcome; providerData?: Record; shutdownResult?: TargetShutdownResult; }; @@ -330,11 +328,12 @@ async function runCloseTeardownAndRelease(params: { }); let session = sessionStore.requireCurrent(ref); const leaseRelease = await releaseProviderLeaseForClose({ + req, session, leaseRegistry, leaseLifecycleProvider, }); - if ('response' in leaseRelease) return { kind: 'response', response: leaseRelease.response }; + if (leaseRelease.response) return { kind: 'response', response: leaseRelease.response }; session = sessionStore.resolveCurrent(ref) ?? session; const cleanupAggregate = closeCleanupError(sessionName, cleanupFailures); const deviceClaimBlockingError = platformCloseError ?? cleanupAggregate; @@ -353,21 +352,9 @@ async function runCloseTeardownAndRelease(params: { await clearDeviceClaim(session.deviceClaim); } sessionStore.retire(ref); - const closeError = deviceClaimBlockingError ?? saveScriptError; - if (closeError) throw withCloseLeaseOutcome(closeError, leaseRelease.lease); - return { - kind: 'closed', - lease: leaseRelease.lease, - providerData: leaseRelease.providerData, - shutdownResult, - }; -} - -/** A close that fails after its lease was released or retained still reports that outcome. */ -function withCloseLeaseOutcome(error: unknown, lease: SessionCloseLeaseOutcome): unknown { - if (lease.outcome === 'none') return error; - const appError = asAppError(error); - return new AppError(appError.code, appError.message, { ...appError.details, lease }, error); + if (deviceClaimBlockingError) throw deviceClaimBlockingError; + if (saveScriptError) throw saveScriptError; + return { kind: 'closed', providerData: leaseRelease.providerData, shutdownResult }; } function buildCloseSuccessResponse(params: { @@ -375,10 +362,9 @@ function buildCloseSuccessResponse(params: { repair: Extract; requestedSaveScript: boolean; shutdownResult: TargetShutdownResult | undefined; - lease: SessionCloseLeaseOutcome; providerData: Record | undefined; }): DaemonResponse { - const { session, repair, requestedSaveScript, shutdownResult, lease, providerData } = params; + const { session, repair, requestedSaveScript, shutdownResult, providerData } = params; if (repair.aborted && requestedSaveScript) { return { ok: false, @@ -386,7 +372,7 @@ function buildCloseSuccessResponse(params: { code: 'COMMAND_FAILED', message: 'The repair was aborted and no script was written.', hint: 'Recovery: replay --from --plan-digest before closing.', - details: { session: session.name, ...(lease.outcome === 'none' ? {} : { lease }) }, + details: { session: session.name }, }, }; } @@ -400,14 +386,14 @@ function buildCloseSuccessResponse(params: { return { ok: true, data: withSuccessText( - { session: session.name, shutdown: shutdownResult, lease, ...savedScript, ...provider }, + { session: session.name, shutdown: shutdownResult, ...savedScript, ...provider }, text, ), }; } return { ok: true, - data: { session: session.name, lease, ...successText(text), ...savedScript, ...provider }, + data: { session: session.name, ...successText(text), ...savedScript, ...provider }, }; } diff --git a/src/__tests__/cli-close.test.ts b/src/__tests__/cli-close.test.ts index 4d2a56d790..9c7d88c5d4 100644 --- a/src/__tests__/cli-close.test.ts +++ b/src/__tests__/cli-close.test.ts @@ -2,6 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; import fs from 'node:fs'; +import path from 'node:path'; import { runCliCapture, type CapturedCliRun } from './cli-capture.ts'; import { connectionWorkspace, @@ -95,7 +96,7 @@ test('close --shutdown --json treats daemon startup failure as no-op success', a }); const PROXY_LEASE_ID = 'abc123abc123abc1'; -const RELEASED = { outcome: 'released', leaseId: PROXY_LEASE_ID } as const; +const ENDED_LEASES = [{ leaseId: PROXY_LEASE_ID, outcome: 'released' }]; function seedProxyConnection(overrides: Partial = {}): string { const { stateDir, remoteConfigPath } = connectionWorkspace('agent-device-close-proxy-'); @@ -148,11 +149,11 @@ function runProxyCli( ); } -test('proxy close forgets the stored lease the daemon reports released, and sends no release', async () => { +test('proxy close forgets the stored lease the daemon reports ended, and sends no release', async () => { const stateDir = seedProxyConnection(); const result = await runProxyCli(stateDir, ['close'], () => ({ ok: true, - data: { session: 'adc-proxy', lease: RELEASED }, + data: { session: 'adc-proxy', endedLeases: ENDED_LEASES }, })); assert.equal(result.code, null); assert.deepEqual( @@ -166,22 +167,22 @@ test('proxy close forgets the stored lease the daemon reports released, and send ); }); -test('a failed proxy close forgets the stored lease the daemon already released', async () => { +test('a failed proxy close forgets the stored lease the daemon already ended', async () => { const stateDir = seedProxyConnection(); const result = await runProxyCli(stateDir, ['close'], () => ({ ok: false, error: { code: 'DEVICE_UNAVAILABLE', message: 'platform close failed', - details: { lease: RELEASED }, + details: { endedLeases: ENDED_LEASES }, }, })); assert.equal(result.code, 1); assert.equal(storedLeaseId(stateDir), undefined); }); -test('a proxy batch forgets the stored lease its close step reports released', async () => { - const closeStep = { step: 1, command: 'close', ok: true, data: { lease: RELEASED } }; +test('a proxy batch forgets the stored lease its reply reports ended by a close step', async () => { + const closeStep = { step: 1, command: 'close', ok: true, data: {}, durationMs: 1 }; const batchReplies: DaemonResponse[] = [ { ok: true, @@ -189,7 +190,8 @@ test('a proxy batch forgets the stored lease its close step reports released', a total: 1, executed: 1, totalDurationMs: 1, - results: [{ ...closeStep, durationMs: 1 }], + results: [closeStep], + endedLeases: ENDED_LEASES, }, }, { @@ -197,7 +199,7 @@ test('a proxy batch forgets the stored lease its close step reports released', a error: { code: 'SESSION_NOT_FOUND', message: 'Batch failed at step 2 (snapshot): No active session', - details: { step: 2, partialResults: [{ ...closeStep, durationMs: 1 }] }, + details: { step: 2, partialResults: [closeStep], endedLeases: ENDED_LEASES }, }, }, ]; @@ -215,3 +217,19 @@ test('a proxy batch forgets the stored lease its close step reports released', a assert.equal(storedLeaseId(stateDir), undefined); } }); + +test('a proxy replay forgets the stored lease its reply reports ended by the script close', async () => { + const stateDir = seedProxyConnection(); + const scriptPath = path.join(stateDir, 'open-close.ad'); + fs.writeFileSync(scriptPath, 'open "com.apple.Preferences"\nclose\n'); + const result = await runProxyCli(stateDir, ['replay', scriptPath], () => ({ + ok: true, + data: { replayed: 2, healed: 0, session: 'adc-proxy', endedLeases: ENDED_LEASES }, + })); + assert.equal(result.code, null); + assert.deepEqual( + result.calls.map((call) => call.command).filter((command) => command !== 'lease_heartbeat'), + ['replay'], + ); + assert.equal(storedLeaseId(stateDir), undefined); +}); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 2798c04b90..a23449959a 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -76,7 +76,6 @@ import { asOptionalRecord, readSnapshotKeyboardBandFact } from '@agent-device/ke import { readSnapshotViewportSize } from '@agent-device/kernel/rect'; import { readResponseWarnings } from '@agent-device/kernel/success-text'; import { createLeaseClient } from './client/lease-client.ts'; -import { readSessionCloseLeaseOutcome } from '@agent-device/contracts/lease-scope'; import { normalizeScreenshotCaptureResult } from './client/screenshot-result.ts'; import { createRequestGuard } from './daemon-client/daemon-client-transport.ts'; @@ -219,7 +218,6 @@ export function createAgentDeviceClient( shutdown: normalizeTargetShutdownResult(data.shutdown), provider: asOptionalRecord(data.provider), savedScript: readOptionalString(data, 'savedScript'), - lease: readSessionCloseLeaseOutcome(data.lease), identifiers: { session }, }; } finally { @@ -326,7 +324,6 @@ export function createAgentDeviceClient( closedApp: options.app, shutdown: normalizeTargetShutdownResult(data.shutdown), savedScript: readOptionalString(data, 'savedScript'), - lease: readSessionCloseLeaseOutcome(data.lease), identifiers: { session }, }; }, diff --git a/src/cli.ts b/src/cli.ts index 198210a3d6..88d9f4abff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -820,15 +820,16 @@ type CliDaemonTransportSetup = { }; /** - * A remote connection reads each daemon reply for a session close that released its stored lease. + * A remote connection reads each daemon reply for the leases its request ended, and forgets the + * stored lease when the reply names it. */ function createCliDaemonTransport(options: CliDaemonTransportSetup): AgentDeviceDaemonTransport { const send = createCliProgressTransport(options); if (!options.flags.remoteConfig) return send; return async (req, context) => { const response = await send(req, context); - const { forgetProxyLeaseReleasedByClose } = await import('./cli/connection/closed-lease.ts'); - forgetProxyLeaseReleasedByClose(options.flags, response); + const { forgetEndedProxyLease } = await import('./cli/connection/ended-lease.ts'); + forgetEndedProxyLease(options.flags, response); return response; }; } diff --git a/src/cli/connection/closed-lease.ts b/src/cli/connection/closed-lease.ts deleted file mode 100644 index 6f79b02637..0000000000 --- a/src/cli/connection/closed-lease.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { DaemonResponse } from '@agent-device/kernel/contracts'; -import { asOptionalRecord } from '@agent-device/kernel/record'; -import type { CliFlags } from '@agent-device/contracts/command'; -import { readSessionCloseLeaseOutcome } from '@agent-device/contracts/lease-scope'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { - readRemoteConnectionState, - writeRemoteConnectionState, -} from '../../remote/remote-connection-state.ts'; -import { isHostAllocatedLease } from '../commands/connection-runtime.ts'; -import { connectionProviderCapabilities } from './provider-policy.ts'; - -/** - * Forgets the stored proxy lease once a daemon reply says a session close released it, so the next - * `open` allocates instead of heartbeating a lease that no longer exists. - */ -export function forgetProxyLeaseReleasedByClose(flags: CliFlags, response: DaemonResponse): void { - if (!flags.remoteConfig) return; - const releasedLeaseIds = closeLeaseFields(response).flatMap((field) => { - const lease = readSessionCloseLeaseOutcome(field); - return lease?.outcome === 'released' ? [lease.leaseId] : []; - }); - if (releasedLeaseIds.length === 0) return; - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; - const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); - if (!state?.leaseId || !releasedLeaseIds.includes(state.leaseId)) return; - if (isHostAllocatedLease(state)) return; - if (connectionProviderCapabilities(state.leaseProvider).leaseKind !== 'proxy') return; - writeRemoteConnectionState({ - stateDir, - state: { ...state, leaseId: undefined, updatedAt: new Date().toISOString() }, - }); -} - -/** - * A close reports its lease on the reply, or in the error details when it failed after the release. - * A batch carries each close step's reply in `results`, or in `partialResults` when a later step - * failed; a batch that failed at the close step carries that step's error details. - */ -function closeLeaseFields(response: DaemonResponse): unknown[] { - const body = response.ok ? response.data : response.error.details; - const steps = response.ok ? body?.results : body?.partialResults; - const stepBodies = Array.isArray(steps) ? steps.map((step) => asOptionalRecord(step)?.data) : []; - return [body, ...stepBodies].map((stepBody) => asOptionalRecord(stepBody)?.lease); -} diff --git a/src/cli/connection/closed-lease.test.ts b/src/cli/connection/ended-lease.test.ts similarity index 61% rename from src/cli/connection/closed-lease.test.ts rename to src/cli/connection/ended-lease.test.ts index 5aa61124ee..eadfc340d2 100644 --- a/src/cli/connection/closed-lease.test.ts +++ b/src/cli/connection/ended-lease.test.ts @@ -8,16 +8,16 @@ import { type StoredConnectionSeed, } from '../../__tests__/remote-connection.fixtures.ts'; import { readRemoteConnectionState } from '../../remote/remote-connection-state.ts'; -import { forgetProxyLeaseReleasedByClose } from './closed-lease.ts'; +import { forgetEndedProxyLease } from './ended-lease.ts'; const LEASE_ID = 'abc123abc123abc1'; -const RELEASED = { outcome: 'released', leaseId: LEASE_ID } as const; +const ENDED = [{ leaseId: LEASE_ID, outcome: 'released' }] as const; function storedLeaseIdAfter( response: DaemonResponse, overrides: Partial = {}, ): string | undefined { - const { stateDir, remoteConfigPath } = connectionWorkspace('agent-device-closed-lease-'); + const { stateDir, remoteConfigPath } = connectionWorkspace('agent-device-ended-lease-'); fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); seedConnectionState({ stateDir, @@ -33,7 +33,7 @@ function storedLeaseIdAfter( ...overrides, }, }); - forgetProxyLeaseReleasedByClose( + forgetEndedProxyLease( { json: true, help: false, @@ -47,40 +47,31 @@ function storedLeaseIdAfter( return readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId; } -const closeStep = { step: 1, command: 'close', ok: true, data: { lease: RELEASED }, durationMs: 1 }; - -test('a released lease is forgotten from a close reply, its error, or a batch step', () => { +test('an ended lease is forgotten from a reply or its error details', () => { const replies: DaemonResponse[] = [ - { ok: true, data: { session: 'adc-proxy', lease: RELEASED } }, - { - ok: false, - error: { code: 'COMMAND_FAILED', message: 'failed', details: { lease: RELEASED } }, - }, - { ok: true, data: { total: 1, executed: 1, totalDurationMs: 1, results: [closeStep] } }, + { ok: true, data: { session: 'adc-proxy', endedLeases: [...ENDED] } }, { ok: false, - error: { - code: 'COMMAND_FAILED', - message: 'step 2', - details: { partialResults: [closeStep] }, - }, + error: { code: 'COMMAND_FAILED', message: 'failed', details: { endedLeases: [...ENDED] } }, }, ]; for (const reply of replies) assert.equal(storedLeaseIdAfter(reply), undefined); }); -test('the stored lease stays unless the reply releases that lease', () => { +test('the stored lease stays unless the reply ends that lease at response level', () => { + const nestedStep = { step: 1, command: 'close', ok: true, data: { endedLeases: [...ENDED] } }; const replies: DaemonResponse[] = [ - { ok: true, data: { lease: { outcome: 'retained', leaseId: LEASE_ID } } }, - { ok: true, data: { lease: { outcome: 'none' } } }, - { ok: true, data: { lease: { outcome: 'released', leaseId: 'other-lease' } } }, + { ok: true, data: { endedLeases: [{ leaseId: 'other-lease', outcome: 'released' }] } }, + { ok: true, data: { endedLeases: [{ leaseId: LEASE_ID, outcome: 'retained' }] } }, + { ok: true, data: { endedLeases: { leaseId: LEASE_ID, outcome: 'released' } } }, { ok: true, data: { lease: { leaseId: LEASE_ID, tenantId: 'proxy', runId: 'run' } } }, + { ok: true, data: { total: 1, executed: 1, results: [nestedStep] } }, ]; for (const reply of replies) assert.equal(storedLeaseIdAfter(reply), LEASE_ID); }); -test('a released lease the CLI does not own stays stored: host-allocated or non-proxy', () => { - const reply: DaemonResponse = { ok: true, data: { lease: RELEASED } }; +test('an ended lease the CLI does not own stays stored: host-allocated or non-proxy', () => { + const reply: DaemonResponse = { ok: true, data: { endedLeases: [...ENDED] } }; assert.equal( storedLeaseIdAfter(reply, { leaseBackend: 'macos-app', platform: 'macos' }), LEASE_ID, diff --git a/src/cli/connection/ended-lease.ts b/src/cli/connection/ended-lease.ts new file mode 100644 index 0000000000..c6e12f5ce4 --- /dev/null +++ b/src/cli/connection/ended-lease.ts @@ -0,0 +1,29 @@ +import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { readEndedLeases } from '@agent-device/contracts/lease-scope'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { + readRemoteConnectionState, + writeRemoteConnectionState, +} from '../../remote/remote-connection-state.ts'; +import { isHostAllocatedLease } from '../commands/connection-runtime.ts'; +import { connectionProviderCapabilities } from './provider-policy.ts'; + +/** + * Forgets the stored proxy lease once a daemon reply says its request ended that lease, so the next + * `open` allocates instead of heartbeating a lease that no longer exists. + */ +export function forgetEndedProxyLease(flags: CliFlags, response: DaemonResponse): void { + if (!flags.remoteConfig) return; + const endedLeaseIds = readEndedLeases(response).map((lease) => lease.leaseId); + if (endedLeaseIds.length === 0) return; + const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); + if (!state?.leaseId || !endedLeaseIds.includes(state.leaseId)) return; + if (isHostAllocatedLease(state)) return; + if (connectionProviderCapabilities(state.leaseProvider).leaseKind !== 'proxy') return; + writeRemoteConnectionState({ + stateDir, + state: { ...state, leaseId: undefined, updatedAt: new Date().toISOString() }, + }); +} diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index ffaa9cf184..1e864a245c 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -247,7 +247,7 @@ Domain client methods: - `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. 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. Pass `leaseId` (16 to 128 hex characters) to `allocate()` to name a new lease with an id the daemon has never issued, then use the returned `leaseId`: a run that already holds a lease gets that one back, and an older daemon mints its own. `sessions.close()` and `apps.close()` return `lease`, which says what the close did with the session's lease: `{ outcome: 'released', leaseId }`, `{ outcome: 'retained', leaseId }`, or `{ outcome: 'none' }`. An older daemon omits it. +- `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. Pass `leaseId` (16 to 128 hex characters) to `allocate()` to name a new lease with an id the daemon has never issued, then use the returned `leaseId`: a run that already holds a lease gets that one back, and an older daemon mints its own. - `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 ae39735c7d..add7878de9 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -45,7 +45,7 @@ agent-device disconnect `--daemon-auth-token ` works instead of the environment variable, but it authenticates only the command you pass it to. Later commands need the token again through the environment variable, a `daemonAuthToken` entry in your remote config profile, or the flag on each command. -`connect proxy` stores the proxy profile and client identity. `open` leases the device for you, and the lease expires after five minutes without commands. A lease allocated directly over the RPC without `ttlMs` gets 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 its device lease, unless the lease was allocated with `retainOnClose`; only `leases.release`, expiry, or daemon shutdown ends that one. The daemon's `close` reply reports what happened to the lease in its `lease` field: `released`, `retained`, or `none` when the session held no lease. A close that fails after the release reports it in the error details. A gateway in front of the daemon reads the lease end from that reply. When the reply says `released`, the CLI forgets the lease, so the next `open` leases a device again. `disconnect` clears local connection state. +`connect proxy` stores the proxy profile and client identity. `open` leases the device for you, and the lease expires after five minutes without commands. A lease allocated directly over the RPC without `ttlMs` gets 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 its device lease, unless the lease was allocated with `retainOnClose`; only `leases.release`, expiry, or daemon shutdown ends that one. The reply to any request that closed a session lists the leases it ended in `endedLeases`, as `{ leaseId, outcome: 'released' }`. This covers `close`, a `batch` with a close step, and a `replay` or `test` script that closes, as well as the session cleanup `test` runs after each script. A successful reply has the field in `data`, and a failed one has it in `error.details`. A gateway in front of the daemon reads the lease end from that reply. When the reply names the stored lease, the CLI forgets it, so the next `open` leases a device again. `disconnect` clears local connection state. `open` names each new lease with an id it makes (32 lowercase hex characters) and sends it with the allocation, so a host in front of the daemon knows the lease id before the daemon allocates. A direct `agent_device.lease.allocate` RPC can pass `leaseId` (16 to 128 hex characters) the same way; an id this daemon has already given a lease, active or ended, is refused with `INVALID_ARGS` and reason `LEASE_ID_IN_USE`, and without one the daemon makes the id. Use the id the reply carries: a run that already holds a lease gets that lease back, and an older daemon ignores the requested id.