diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index 0dfbf82532..ccae133887 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -146,7 +146,7 @@ Thus, a handler that ignores its cancellation cannot hold a rented device open. The inactivity TTL that the client asked for determines which leases this preservation reaches. The daemon default is one minute. -A cloud WebDriver connection profile asks for ten minutes. +A remote device connection profile asks for five minutes. Thus, a single command that runs longer than its own lease is ordinary on the default. On the longer window, such a command is only reachable through a profile. diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 2e71dc5fca..1290aa969c 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -483,6 +483,8 @@ export function findMissingProxyLeaseFields(scope: LeaseScope): string[] { * yet must assume this one, or it can be planning for a window the daemon would never grant. */ export const MIN_LEASE_WINDOW_MS = 5_000; +/** The idle window of every remote device lease: proxy, cloud device and cloud WebDriver. */ +export const REMOTE_LEASE_IDLE_TTL_MS = 5 * 60 * 1000; /** * Why a lease stopped being ours: it is gone, spent, or taken back. diff --git a/packages/contracts/src/provider-connection.ts b/packages/contracts/src/provider-connection.ts index a0ff737e9e..a312be83db 100644 --- a/packages/contracts/src/provider-connection.ts +++ b/packages/contracts/src/provider-connection.ts @@ -26,4 +26,10 @@ export type ConnectionProviderCapabilities = { supportsDeferredAppSelection: boolean; supportsDirectPortReverse: boolean; usesCloudWebDriverLease: boolean; + /** + * The provider leases a cloud-hosted device that bills while held. Its lease requests the same + * idle window as a proxy or cloud WebDriver lease on each lease-using command, not the daemon + * default. + */ + holdsCloudDevice?: boolean; }; diff --git a/packages/plugins/src/manifest.test.ts b/packages/plugins/src/manifest.test.ts index 78c38d1faf..24507d559b 100644 --- a/packages/plugins/src/manifest.test.ts +++ b/packages/plugins/src/manifest.test.ts @@ -54,6 +54,8 @@ test('connection metadata admits local providers and rejects malformed or remote return readPluginManifest(directory); }; assert.deepEqual(read(connection).agentDevicePlugin.connection, connection); + const cloud = { ...connection, holdsCloudDevice: true }; + assert.deepEqual(read(cloud).agentDevicePlugin.connection, cloud); for (const invalid of [ null, {}, @@ -61,6 +63,7 @@ test('connection metadata admits local providers and rejects malformed or remote { ...connection, requiresRemoteDaemon: true }, { ...connection, supportsArtifacts: 'true' }, { ...connection, supportsDeferredAppSelection: undefined }, + { ...connection, holdsCloudDevice: 'false' }, ]) { assert.throws(() => read(invalid), { code: 'INVALID_ARGS' }); } diff --git a/packages/plugins/src/manifest.ts b/packages/plugins/src/manifest.ts index 440725b231..9a1aa61549 100644 --- a/packages/plugins/src/manifest.ts +++ b/packages/plugins/src/manifest.ts @@ -43,6 +43,7 @@ export function assertLocalConnectionCapabilities( 'supportsDirectPortReverse', 'usesCloudWebDriverLease', ].some((key) => typeof policy[key as keyof ConnectionProviderCapabilities] !== 'boolean') || + (policy.holdsCloudDevice !== undefined && typeof policy.holdsCloudDevice !== 'boolean') || policy.requiresRemoteDaemon ) { throw invalidManifest('Plugin connection must declare local provider capabilities'); diff --git a/packages/provider-limrun/src/runtime-dependencies.test.ts b/packages/provider-limrun/src/runtime-dependencies.test.ts index 4e7279294d..4e4a28cfbc 100644 --- a/packages/provider-limrun/src/runtime-dependencies.test.ts +++ b/packages/provider-limrun/src/runtime-dependencies.test.ts @@ -191,6 +191,7 @@ test('allocation installs an exact uploaded asset before binding its application }, }, spec: { + inactivityTimeout: '5m', initialAssets: [ { kind: 'App', diff --git a/packages/provider-limrun/src/session-allocation.test.ts b/packages/provider-limrun/src/session-allocation.test.ts index 60096de044..a8b68cfb12 100644 --- a/packages/provider-limrun/src/session-allocation.test.ts +++ b/packages/provider-limrun/src/session-allocation.test.ts @@ -63,6 +63,27 @@ test('the iOS instance create asks the SDK for one attempt, so a lost answer ren assert.deepEqual(rented, []); }); +test.each([ + ['Android', allocateLimrunAndroidSession, 'POST /v1/android_instances'], + ['iOS', allocateLimrunIosSession, 'POST /v1/ios_instances'], +] as const)( + 'the %s instance create asks for a five minute inactivity timeout', + async (_platform, allocate, route) => { + const bodies: string[] = []; + const server = await startInstanceServer(async (request) => { + if (`${String(request.method)} ${request.path}` === route) bodies.push(request.body); + request.drop(); + }); + try { + await assert.rejects(async () => await allocate(allocationParams(server), () => {})); + } finally { + await server.close(); + } + const spec = (JSON.parse(bodies[0] ?? '{}') as { spec?: { inactivityTimeout?: string } }).spec; + assert.equal(spec?.inactivityTimeout, '5m'); + }, +); + test('an Android instance that answers without its endpoints is reported as rented and left for its owner', async () => { const routes: string[] = []; const server = await startInstanceServer(async (request) => { @@ -177,6 +198,7 @@ type InstanceServer = Readonly<{ type ReceivedRequest = Readonly<{ method: string | undefined; path: string; + body: string; /** Ends the connection without an answer, the ambiguity a retry must not resolve by POSTing again. */ drop(): void; respond(status: number, body: string): Promise; @@ -193,6 +215,7 @@ function startInstanceServer( method: req.method, // The query is Limrun's own create parameters, not part of the route a retry would repeat. path: (req.url ?? '').split('?')[0] ?? '', + body: Buffer.concat(chunks).toString('utf8'), drop: () => req.socket.destroy(), respond: async (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); diff --git a/packages/provider-limrun/src/session-allocation.ts b/packages/provider-limrun/src/session-allocation.ts index 07fe452ccf..a0eb860386 100644 --- a/packages/provider-limrun/src/session-allocation.ts +++ b/packages/provider-limrun/src/session-allocation.ts @@ -1,5 +1,6 @@ import type Limrun from '@limrun/api'; import type { DeviceLease, LeaseLifecycleContext } from '@agent-device/contracts/device'; +import { REMOTE_LEASE_IDLE_TTL_MS } from '@agent-device/contracts/lease-scope'; import { AppError } from '@agent-device/kernel/errors'; import { rejectRefusedProviderProfileFields } from '@agent-device/contracts/provider-profile-fields'; import { createLimrunAndroidSession, type LimrunAndroidSession } from './android.ts'; @@ -15,6 +16,9 @@ import { import { createLimrunDeviceSession } from './device-session.ts'; import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; +/** A created instance outlives its lease's idle window, so Limrun's 3 minute default never ends it first. */ +const LIMRUN_INACTIVITY_TIMEOUT = `${REMOTE_LEASE_IDLE_TTL_MS / 60_000}m`; + type LimrunInstance = { metadata: { id: string }; status: { @@ -75,6 +79,7 @@ export async function allocateLimrunIosSession( wait: true, metadata: params.metadata, spec: { + inactivityTimeout: LIMRUN_INACTIVITY_TIMEOUT, ...(params.region ? { region: params.region } : {}), ...(params.app ? { @@ -123,6 +128,7 @@ export async function allocateLimrunAndroidSession( wait: true, metadata: params.metadata, spec: { + inactivityTimeout: LIMRUN_INACTIVITY_TIMEOUT, ...(params.region ? { region: params.region } : {}), ...(params.app ? { diff --git a/packages/provider-revyl/package.json b/packages/provider-revyl/package.json index 70ae608ed5..df90360bed 100644 --- a/packages/provider-revyl/package.json +++ b/packages/provider-revyl/package.json @@ -41,7 +41,8 @@ "supportsArtifacts": false, "supportsDeferredAppSelection": false, "supportsDirectPortReverse": false, - "usesCloudWebDriverLease": false + "usesCloudWebDriverLease": false, + "holdsCloudDevice": true }, "credentialVariables": [ "REVYL_API_KEY", diff --git a/packages/provider-revyl/src/index.test.ts b/packages/provider-revyl/src/index.test.ts index db6adbb8a3..b1668fd66f 100644 --- a/packages/provider-revyl/src/index.test.ts +++ b/packages/provider-revyl/src/index.test.ts @@ -70,6 +70,7 @@ test('the plugin declares what a Revyl lease is and is not', () => { supportsDeferredAppSelection: false, supportsDirectPortReverse: false, usesCloudWebDriverLease: false, + holdsCloudDevice: true, }); }); diff --git a/packages/provider-revyl/src/index.ts b/packages/provider-revyl/src/index.ts index 9790583c39..6f419fbeea 100644 --- a/packages/provider-revyl/src/index.ts +++ b/packages/provider-revyl/src/index.ts @@ -48,6 +48,7 @@ function revyl(options: RevylPluginOptions = {}): AgentDevicePlugin { supportsDeferredAppSelection: false, supportsDirectPortReverse: false, usesCloudWebDriverLease: false, + holdsCloudDevice: true, }, resolve: (context) => import('./connection.ts').then(({ createRevylConnection }) => diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index b57ab6903f..9f9bda97b0 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -28,10 +28,7 @@ import { materializeRemoteConnectionForCommand, } from './test-utils/host-bound-commands.ts'; import { writeGeneratedRemoteConfig } from '../cli/connection/generated-config.ts'; -import { - CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, - PROXY_REMOTE_LEASE_TTL_MS, -} from '../cli/commands/connection-lease-policy.ts'; +import { REMOTE_LEASE_IDLE_TTL_MS } from '@agent-device/contracts/lease-scope'; import { hasDeferredMetroConfig } from '../cli/commands/connection-metro-runtime.ts'; import { stopMetroTunnel } from '@agent-device/react-native'; import { AppError } from '@agent-device/kernel/errors'; @@ -629,7 +626,7 @@ test('proxy open resolves device key before allocating lease', async () => { assert.equal(allocate.request?.leaseProvider, 'proxy'); assert.equal(allocate.request?.clientId, 'client-1'); assert.equal(allocate.request?.deviceKey, 'ios:mobile:SIM-001'); - assert.equal(allocate.request?.ttlMs, PROXY_REMOTE_LEASE_TTL_MS); + assert.equal(allocate.request?.ttlMs, REMOTE_LEASE_IDLE_TTL_MS); assert.equal(allocate.request?.leaseBackend, 'ios-instance'); assert.equal(materialized.flags.leaseId, 'abc123abc123abc1'); assert.equal(materialized.flags.udid, 'SIM-001'); @@ -683,7 +680,7 @@ test.each(['install', 'uninstall'])('proxy %s allocates a lease first', async (c assert.equal(allocate.request?.leaseProvider, 'proxy'); assert.equal(allocate.request?.clientId, 'client-1'); assert.equal(allocate.request?.deviceKey, 'android:mobile:emulator-5554'); - assert.equal(allocate.request?.ttlMs, PROXY_REMOTE_LEASE_TTL_MS); + assert.equal(allocate.request?.ttlMs, REMOTE_LEASE_IDLE_TTL_MS); assert.equal(allocate.request?.leaseBackend, 'android-instance'); assert.equal(materialized.flags.leaseId, 'android-lease-1'); assert.equal(materialized.flags.serial, 'emulator-5554'); @@ -783,7 +780,7 @@ test('cloud webdriver connection allocates and heartbeats with extended lease TT }); assert.equal(allocateRequest?.leaseProvider, 'aws-device-farm'); - assert.equal(allocateRequest?.ttlMs, CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS); + assert.equal(allocateRequest?.ttlMs, REMOTE_LEASE_IDLE_TTL_MS); assert.equal(materialized.flags.leaseId, 'cloud-lease-1'); let heartbeatRequest: Parameters[0] | undefined; @@ -805,7 +802,7 @@ test('cloud webdriver connection allocates and heartbeats with extended lease TT }); assert.equal(heartbeatRequest?.leaseId, 'cloud-lease-1'); - assert.equal(heartbeatRequest?.ttlMs, CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS); + assert.equal(heartbeatRequest?.ttlMs, REMOTE_LEASE_IDLE_TTL_MS); fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/builtin-provider-ids.ts b/src/builtin-provider-ids.ts index 3f547a4cb5..7e2e6194a8 100644 --- a/src/builtin-provider-ids.ts +++ b/src/builtin-provider-ids.ts @@ -12,6 +12,7 @@ const LIMRUN_CONNECTION: ConnectionProviderCapabilities = { supportsDeferredAppSelection: true, supportsDirectPortReverse: true, usesCloudWebDriverLease: false, + holdsCloudDevice: true, }; /** Every provider id agent-device registers itself; a plugin cannot claim any of them. */ diff --git a/src/cli/commands/connection-lease-policy.test.ts b/src/cli/commands/connection-lease-policy.test.ts new file mode 100644 index 0000000000..825f1e41bf --- /dev/null +++ b/src/cli/commands/connection-lease-policy.test.ts @@ -0,0 +1,48 @@ +import { expect, test, vi } from 'vitest'; +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; +import type { RemoteConnectionState } from '@agent-device/remote/remote-connection-state'; +import { REMOTE_LEASE_IDLE_TTL_MS } from '@agent-device/contracts/lease-scope'; +import { connectionLeasePolicyForState } from './connection-lease-policy.ts'; + +const LOCAL_PROVIDER: ConnectionProviderCapabilities = { + leaseKind: 'direct-device-provider', + requiresAppAttachment: false, + requiresRemoteDaemon: false, + supportsArtifacts: false, + supportsDeferredAppSelection: false, + supportsDirectPortReverse: false, + usesCloudWebDriverLease: false, +}; + +const providers: Record = { + proxy: { ...LOCAL_PROVIDER, leaseKind: 'proxy', requiresRemoteDaemon: true }, + 'cloud-device': { ...LOCAL_PROVIDER, holdsCloudDevice: true }, + 'cloud-device-deferred': { + ...LOCAL_PROVIDER, + supportsDeferredAppSelection: true, + holdsCloudDevice: true, + }, + 'cloud-webdriver': { ...LOCAL_PROVIDER, usesCloudWebDriverLease: true }, + 'local-device': LOCAL_PROVIDER, + 'malformed-flag': { ...LOCAL_PROVIDER, holdsCloudDevice: 'false' as never }, +}; + +vi.mock('../connection/provider-policy.ts', () => ({ + connectionProviderCapabilities: (provider: string) => providers[provider], +})); + +function ttlFor(leaseProvider: string): number | undefined { + const state = { leaseProvider } as RemoteConnectionState; + return connectionLeasePolicyForState(state).ttlMs(state); +} + +test.each([ + ['proxy', REMOTE_LEASE_IDLE_TTL_MS], + ['cloud-device', REMOTE_LEASE_IDLE_TTL_MS], + ['cloud-device-deferred', REMOTE_LEASE_IDLE_TTL_MS], + ['cloud-webdriver', REMOTE_LEASE_IDLE_TTL_MS], + ['local-device', undefined], + ['malformed-flag', undefined], +])('a %s lease requests TTL %s', (provider, expected) => { + expect(ttlFor(provider)).toBe(expected); +}); diff --git a/src/cli/commands/connection-lease-policy.ts b/src/cli/commands/connection-lease-policy.ts index f8d2156ff5..508123c9f4 100644 --- a/src/cli/commands/connection-lease-policy.ts +++ b/src/cli/commands/connection-lease-policy.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import { REMOTE_LEASE_IDLE_TTL_MS } from '@agent-device/contracts/lease-scope'; import { AppError } from '@agent-device/kernel/errors'; import type { LeaseBackend } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; @@ -17,8 +18,14 @@ const leaseDeferredCommands = new Set([ 'metro', 'session', ]); -export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; -export const CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS = 10 * 60 * 1000; +function remoteLeaseTtlMs(state: RemoteConnectionState): number | undefined { + const capabilities = connectionProviderCapabilities(state.leaseProvider); + return capabilities.leaseKind === 'proxy' || + capabilities.usesCloudWebDriverLease || + capabilities.holdsCloudDevice === true + ? REMOTE_LEASE_IDLE_TTL_MS + : undefined; +} export type ConnectionLeasePolicy = { shouldAllocate(command: string): boolean; @@ -43,28 +50,25 @@ export function connectionLeasePolicyForState(state: RemoteConnectionState): Con if (capabilities.supportsDeferredAppSelection) { return DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY; } - if (capabilities.usesCloudWebDriverLease) { - return CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY; - } return DEFAULT_CONNECTION_LEASE_POLICY; } const DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => command !== PUBLIC_COMMANDS.apps && !leaseDeferredCommands.has(command), - ttlMs: () => undefined, + ttlMs: remoteLeaseTtlMs, resolveLeaseState: async (options) => ({ state: options.state }), }; const DEFAULT_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => !leaseDeferredCommands.has(command), - ttlMs: () => undefined, + ttlMs: remoteLeaseTtlMs, resolveLeaseState: async (options) => ({ state: options.state }), }; const PROXY_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => command !== 'devices' && !leaseDeferredCommands.has(command), - ttlMs: () => PROXY_REMOTE_LEASE_TTL_MS, + ttlMs: remoteLeaseTtlMs, // Known before the daemon allocates, so a host in front of it can name the lease with the same id. newLeaseId: () => crypto.randomBytes(16).toString('hex'), resolveLeaseState: resolveProxyLeaseState, @@ -87,9 +91,3 @@ const MACOS_APP_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { return { state: { ...state, platform: 'macos' } }; }, }; - -const CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { - shouldAllocate: (command) => !leaseDeferredCommands.has(command), - ttlMs: () => CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, - resolveLeaseState: async (options) => ({ state: options.state }), -}; diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index 70c76430e5..0306775d64 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -25,6 +25,7 @@ test('provider policy projects provider identity into semantic capabilities', () supportsDeferredAppSelection: true, supportsDirectPortReverse: true, usesCloudWebDriverLease: false, + holdsCloudDevice: true, }); const browserStack = connectionProviderCapabilities('browserstack'); assert.equal(browserStack.supportsArtifacts, true); diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 9fb9211e1e..5f4114574e 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -53,6 +53,13 @@ If your shell's account variables, or its instance variables for the platform yo Limrun ends an instance after its inactivity timeout, which an idle session can reach while a model thinks between steps. Set `LIMRUN_KEEP_ALIVE=1` (or `true`) to ping the instance every 30 seconds while a session is open. Keep-alive is off by default. In the Node.js runtime, pass `keepAlive: true`. +Two timers can end an idle Limrun session. + +- The session lease. Each command that uses the lease renews it for five minutes. After five minutes without such a command the lease expires, and the daemon ends the session. The next command that uses the lease allocates a new lease, and the app you opened is not restored. +- The Limrun instance. An instance that agent-device creates ends after five minutes without activity, the same window as the lease. An instance you attach to keeps the inactivity timeout it was created with. When Limrun ends an instance, every later command fails with `WebSocket is not connected` until the lease expires or you run `disconnect`. + +`LIMRUN_KEEP_ALIVE` keeps the instance running past five minutes. It does not renew the lease. + ## Run a session from the CLI A new Limrun instance does not contain your app. Run `install ` before `open`. `install` allocates the instance when needed, so you do not need to run `devices` first. diff --git a/website/docs/docs/plugins.md b/website/docs/docs/plugins.md index 89d000d290..8c3043f40b 100644 --- a/website/docs/docs/plugins.md +++ b/website/docs/docs/plugins.md @@ -121,13 +121,14 @@ connection: { supportsDeferredAppSelection: true, supportsDirectPortReverse: false, usesCloudWebDriverLease: false, + holdsCloudDevice: false, }, resolve: async ({ flags, env, cwd, stateDir, host }) => ({ profile }), verify: async ({ flags, env, host }) => verification, }, ``` -`resolve` validates provider flags and returns `{ profile, extraFlags? }`; `profile.leaseProvider` must match the plugin's `provider`. The host supplies connection identity, session defaults, Metro settings, and persists the profile. `verify` returns the provider verification result. Both also receive `host`, the same `ProviderPluginHost` that `setup` gets, built from the `env` of that call. Both run without allocating a device or setting up a runtime. `agent-device` loads installed plugins' factories, not their `setup`, to read these capabilities. +`resolve` validates provider flags and returns `{ profile, extraFlags? }`; `profile.leaseProvider` must match the plugin's `provider`. The host supplies connection identity, session defaults, Metro settings, and persists the profile. `verify` returns the provider verification result. Both also receive `host`, the same `ProviderPluginHost` that `setup` gets, built from the `env` of that call. Both run without allocating a device or setting up a runtime. `agent-device` loads installed plugins' factories, not their `setup`, to read these capabilities. Set the optional `holdsCloudDevice: true` when the provider leases a cloud-hosted device. Each command that uses the lease then requests a five-minute lease window, the same as a proxy or cloud WebDriver lease, not the 60-second daemon default. A daemon whose `AGENT_DEVICE_LEASE_MAX_TTL_MS` is below five minutes refuses that request with `INVALID_ARGS`. ### Package for installation