From a6614e2ebf4ce64c6d7cc3218068d68c2533337a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 4 Nov 2026 18:37:18 +0100 Subject: [PATCH 01/10] fix(cli): give cloud device leases a 10 minute TTL Limrun and Revyl leases fell to the daemon's 60 second default, so a session ended after one minute without a command. A new holdsCloudDevice capability selects the cloud device lease TTL, shared with cloud WebDriver leases. Co-Authored-By: Claude --- packages/contracts/src/provider-connection.ts | 2 + packages/provider-revyl/package.json | 3 +- packages/provider-revyl/src/index.ts | 1 + src/__tests__/remote-connection.test.ts | 78 ++++++++++++++++++- src/builtin-provider-ids.ts | 1 + src/cli/commands/connection-lease-policy.ts | 14 +++- website/docs/docs/limrun.md | 2 + website/docs/docs/plugins.md | 3 +- 8 files changed, 95 insertions(+), 9 deletions(-) diff --git a/packages/contracts/src/provider-connection.ts b/packages/contracts/src/provider-connection.ts index a0ff737e9e..970faa688b 100644 --- a/packages/contracts/src/provider-connection.ts +++ b/packages/contracts/src/provider-connection.ts @@ -26,4 +26,6 @@ export type ConnectionProviderCapabilities = { supportsDeferredAppSelection: boolean; supportsDirectPortReverse: boolean; usesCloudWebDriverLease: boolean; + /** The provider leases a cloud-hosted device that bills while held; its lease gets the cloud lease TTL. */ + holdsCloudDevice?: boolean; }; 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.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..73fb435792 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -29,7 +29,7 @@ import { } from './test-utils/host-bound-commands.ts'; import { writeGeneratedRemoteConfig } from '../cli/connection/generated-config.ts'; import { - CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, + CLOUD_DEVICE_LEASE_TTL_MS, PROXY_REMOTE_LEASE_TTL_MS, } from '../cli/commands/connection-lease-policy.ts'; import { hasDeferredMetroConfig } from '../cli/commands/connection-metro-runtime.ts'; @@ -783,7 +783,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, CLOUD_DEVICE_LEASE_TTL_MS); assert.equal(materialized.flags.leaseId, 'cloud-lease-1'); let heartbeatRequest: Parameters[0] | undefined; @@ -805,7 +805,79 @@ 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, CLOUD_DEVICE_LEASE_TTL_MS); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('limrun connection allocates and heartbeats with the cloud device lease TTL', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-limrun-ttl-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({})); + seedConnectionState({ + stateDir, + state: { + session: 'limrun-ttl', + remoteConfigPath, + tenant: 'acme', + runId: 'run-123', + leaseProvider: 'limrun', + platform: 'ios', + }, + }); + const baseFlags = { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + tenant: 'acme', + runId: 'run-123', + session: 'limrun-ttl', + platform: 'ios', + } as const; + let allocateRequest: Parameters[0] | undefined; + + await materializeRemoteConnectionForCommand({ + command: 'snapshot', + flags: { ...baseFlags }, + client: createTestClient({ + allocate: async (request) => { + allocateRequest = request; + return { + leaseId: 'limrun-lease-1', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'ios-instance', + leaseProvider: request.leaseProvider, + }; + }, + }), + }); + + assert.equal(allocateRequest?.leaseProvider, 'limrun'); + assert.equal(allocateRequest?.ttlMs, CLOUD_DEVICE_LEASE_TTL_MS); + + let heartbeatRequest: Parameters[0] | undefined; + await materializeRemoteConnectionForCommand({ + command: 'snapshot', + flags: { ...baseFlags }, + client: createTestClient({ + heartbeat: async (request) => { + heartbeatRequest = request; + return { + leaseId: request.leaseId, + tenantId: request.tenant ?? 'acme', + runId: request.runId ?? 'run-123', + backend: request.leaseBackend ?? 'ios-instance', + leaseProvider: request.leaseProvider, + }; + }, + }), + }); + + assert.equal(heartbeatRequest?.leaseId, 'limrun-lease-1'); + assert.equal(heartbeatRequest?.ttlMs, CLOUD_DEVICE_LEASE_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.ts b/src/cli/commands/connection-lease-policy.ts index f8d2156ff5..638f0e50e5 100644 --- a/src/cli/commands/connection-lease-policy.ts +++ b/src/cli/commands/connection-lease-policy.ts @@ -18,7 +18,13 @@ const leaseDeferredCommands = new Set([ 'session', ]); export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; -export const CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS = 10 * 60 * 1000; +export const CLOUD_DEVICE_LEASE_TTL_MS = 10 * 60 * 1000; + +function cloudDeviceLeaseTtlMs(state: RemoteConnectionState): number | undefined { + return connectionProviderCapabilities(state.leaseProvider).holdsCloudDevice + ? CLOUD_DEVICE_LEASE_TTL_MS + : undefined; +} export type ConnectionLeasePolicy = { shouldAllocate(command: string): boolean; @@ -52,13 +58,13 @@ export function connectionLeasePolicyForState(state: RemoteConnectionState): Con const DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => command !== PUBLIC_COMMANDS.apps && !leaseDeferredCommands.has(command), - ttlMs: () => undefined, + ttlMs: cloudDeviceLeaseTtlMs, resolveLeaseState: async (options) => ({ state: options.state }), }; const DEFAULT_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => !leaseDeferredCommands.has(command), - ttlMs: () => undefined, + ttlMs: cloudDeviceLeaseTtlMs, resolveLeaseState: async (options) => ({ state: options.state }), }; @@ -90,6 +96,6 @@ const MACOS_APP_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { const CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => !leaseDeferredCommands.has(command), - ttlMs: () => CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, + ttlMs: () => CLOUD_DEVICE_LEASE_TTL_MS, resolveLeaseState: async (options) => ({ state: options.state }), }; diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 9fb9211e1e..b2976ad7b6 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -53,6 +53,8 @@ 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`. +`LIMRUN_KEEP_ALIVE` only keeps the Limrun instance running. It does not renew the session lease. The daemon renews the lease on every command and ends the session after 10 minutes without one, so a pause longer than 10 minutes between commands needs a new `connect`. Without keep-alive, Limrun can end the instance during a pause of a few minutes, even while the lease is active. + ## 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..4b3cf7caa6 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. The CLI then renews that lease for 10 minutes on each command, not the 60-second daemon default. ### Package for installation From 74a1be0336e34d304873a2de67fb72d3ce0cba13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 4 Nov 2026 18:37:50 +0100 Subject: [PATCH 02/10] test(cli): cover the cloud device lease TTL in the policy Co-Authored-By: Claude --- src/__tests__/remote-connection.test.ts | 72 ------------------- .../commands/connection-lease-policy.test.ts | 22 ++++++ 2 files changed, 22 insertions(+), 72 deletions(-) create mode 100644 src/cli/commands/connection-lease-policy.test.ts diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 73fb435792..65ee9b4a89 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -809,78 +809,6 @@ test('cloud webdriver connection allocates and heartbeats with extended lease TT fs.rmSync(tempRoot, { recursive: true, force: true }); }); -test('limrun connection allocates and heartbeats with the cloud device lease TTL', async () => { - const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( - 'agent-device-connect-limrun-ttl-', - ); - fs.writeFileSync(remoteConfigPath, JSON.stringify({})); - seedConnectionState({ - stateDir, - state: { - session: 'limrun-ttl', - remoteConfigPath, - tenant: 'acme', - runId: 'run-123', - leaseProvider: 'limrun', - platform: 'ios', - }, - }); - const baseFlags = { - json: true, - help: false, - version: false, - stateDir, - remoteConfig: remoteConfigPath, - tenant: 'acme', - runId: 'run-123', - session: 'limrun-ttl', - platform: 'ios', - } as const; - let allocateRequest: Parameters[0] | undefined; - - await materializeRemoteConnectionForCommand({ - command: 'snapshot', - flags: { ...baseFlags }, - client: createTestClient({ - allocate: async (request) => { - allocateRequest = request; - return { - leaseId: 'limrun-lease-1', - tenantId: request.tenant, - runId: request.runId, - backend: request.leaseBackend ?? 'ios-instance', - leaseProvider: request.leaseProvider, - }; - }, - }), - }); - - assert.equal(allocateRequest?.leaseProvider, 'limrun'); - assert.equal(allocateRequest?.ttlMs, CLOUD_DEVICE_LEASE_TTL_MS); - - let heartbeatRequest: Parameters[0] | undefined; - await materializeRemoteConnectionForCommand({ - command: 'snapshot', - flags: { ...baseFlags }, - client: createTestClient({ - heartbeat: async (request) => { - heartbeatRequest = request; - return { - leaseId: request.leaseId, - tenantId: request.tenant ?? 'acme', - runId: request.runId ?? 'run-123', - backend: request.leaseBackend ?? 'ios-instance', - leaseProvider: request.leaseProvider, - }; - }, - }), - }); - - assert.equal(heartbeatRequest?.leaseId, 'limrun-lease-1'); - assert.equal(heartbeatRequest?.ttlMs, CLOUD_DEVICE_LEASE_TTL_MS); - fs.rmSync(tempRoot, { recursive: true, force: true }); -}); - test('proxy commands without active device lease fail before allocation', async () => { const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( 'agent-device-connect-proxy-closed-', 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..9356e05b2f --- /dev/null +++ b/src/cli/commands/connection-lease-policy.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vitest'; +import type { RemoteConnectionState } from '@agent-device/remote/remote-connection-state'; +import { + CLOUD_DEVICE_LEASE_TTL_MS, + connectionLeasePolicyForState, +} from './connection-lease-policy.ts'; + +function stateFor(leaseProvider: string): RemoteConnectionState { + return { leaseProvider } as RemoteConnectionState; +} + +test.each(['limrun', 'aws-device-farm'])('a %s lease uses the cloud device TTL', (provider) => { + const state = stateFor(provider); + + expect(connectionLeasePolicyForState(state).ttlMs(state)).toBe(CLOUD_DEVICE_LEASE_TTL_MS); +}); + +test('a lease for a provider without a cloud device keeps the daemon default TTL', () => { + const state = stateFor('webos'); + + expect(connectionLeasePolicyForState(state).ttlMs(state)).toBeUndefined(); +}); From e336f0aad772a96fe3d142d17242afaa5b759abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 4 Nov 2026 18:44:04 +0100 Subject: [PATCH 03/10] test(cli): expect the cloud device capability in provider declarations Co-Authored-By: Claude --- packages/provider-revyl/src/index.test.ts | 1 + src/cli/connection/provider-policy.test.ts | 1 + 2 files changed, 2 insertions(+) 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/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); From 03f7070d414c690572638ab8623b09ac033f3550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 4 Nov 2026 19:05:27 +0100 Subject: [PATCH 04/10] fix(cli): one cloud lease TTL rule, validated capability, daemon ceiling clamp Co-Authored-By: Claude --- packages/contracts/src/lease-scope.ts | 7 +++ packages/contracts/src/provider-connection.ts | 5 +- .../__tests__/lease-registry-scope.test.ts | 7 +++ .../src/resources/lease-registry-scope.ts | 4 +- packages/plugins/src/manifest.test.ts | 3 + packages/plugins/src/manifest.ts | 1 + src/__tests__/remote-connection.test.ts | 6 +- .../commands/connection-lease-policy.test.ts | 60 ++++++++++++++----- src/cli/commands/connection-lease-policy.ts | 8 ++- website/docs/docs/limrun.md | 7 ++- website/docs/docs/plugins.md | 2 +- 11 files changed, 83 insertions(+), 27 deletions(-) diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 2e71dc5fca..6675d541bd 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -483,6 +483,13 @@ 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 default ceiling for a lease TTL a caller may request. */ +export const MAX_LEASE_TTL_MS = 10 * 60_000; +/** + * The TTL a cloud device lease requests so a session survives an agent's think time. A daemon whose + * ceiling is lower than this grants its ceiling instead of refusing the request. + */ +export const CLOUD_DEVICE_LEASE_TTL_MS = MAX_LEASE_TTL_MS; /** * 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 970faa688b..114dd05483 100644 --- a/packages/contracts/src/provider-connection.ts +++ b/packages/contracts/src/provider-connection.ts @@ -26,6 +26,9 @@ export type ConnectionProviderCapabilities = { supportsDeferredAppSelection: boolean; supportsDirectPortReverse: boolean; usesCloudWebDriverLease: boolean; - /** The provider leases a cloud-hosted device that bills while held; its lease gets the cloud lease TTL. */ + /** + * The provider leases a cloud-hosted device that bills while held. A lease with this capability or + * `usesCloudWebDriverLease` requests the cloud device lease TTL on each lease-using command. + */ holdsCloudDevice?: boolean; }; diff --git a/packages/daemon/src/resources/__tests__/lease-registry-scope.test.ts b/packages/daemon/src/resources/__tests__/lease-registry-scope.test.ts index a9d046adce..bec87250e6 100644 --- a/packages/daemon/src/resources/__tests__/lease-registry-scope.test.ts +++ b/packages/daemon/src/resources/__tests__/lease-registry-scope.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; +import { CLOUD_DEVICE_LEASE_TTL_MS } from '@agent-device/contracts/lease-scope'; import { createLeaseTtlResolver, leaseDeviceBindingKey, @@ -49,3 +50,9 @@ test('lease TTL normalization retains defaults, limits, and invalid configuratio assert.equal(configured(1), 1); assert.throws(() => configured(2), { code: 'INVALID_ARGS' }); }); + +test('a lowered ceiling grants the cloud device TTL its ceiling and still refuses other excess', () => { + const lowered = createLeaseTtlResolver({ maxLeaseTtlMs: 300_000 }); + assert.equal(lowered(CLOUD_DEVICE_LEASE_TTL_MS), 300_000); + assert.throws(() => lowered(300_001), { code: 'INVALID_ARGS' }); +}); diff --git a/packages/daemon/src/resources/lease-registry-scope.ts b/packages/daemon/src/resources/lease-registry-scope.ts index 4f9c101c46..e13ebd82b5 100644 --- a/packages/daemon/src/resources/lease-registry-scope.ts +++ b/packages/daemon/src/resources/lease-registry-scope.ts @@ -1,6 +1,8 @@ import crypto from 'node:crypto'; import type { DeviceLease } from '@agent-device/contracts/device'; import { + CLOUD_DEVICE_LEASE_TTL_MS, + MAX_LEASE_TTL_MS, MIN_LEASE_WINDOW_MS, leaseScopeToReleaseRequest, } from '@agent-device/contracts/lease-scope'; @@ -98,7 +100,6 @@ export type NormalizedAllocateLeaseRequest = { }; const DEFAULT_LEASE_TTL_MS = 60_000; -const MAX_LEASE_TTL_MS = 10 * 60_000; const DEFAULT_LEASE_PROVIDER = 'default'; export function createLeaseTtlResolver(options: LeaseRegistryOptions) { @@ -114,6 +115,7 @@ export function createLeaseTtlResolver(options: LeaseRegistryOptions) { return (raw: number | undefined): number => { if (!Number.isInteger(raw)) return defaultTtl; const value = Number(raw); + if (value === CLOUD_DEVICE_LEASE_TTL_MS && value > maxTtl) return maxTtl; if (value < minTtl || value > maxTtl) { throw new AppError('INVALID_ARGS', `Lease ttlMs must be between ${minTtl} and ${maxTtl}.`); } 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/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 65ee9b4a89..e039953420 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -28,10 +28,8 @@ import { materializeRemoteConnectionForCommand, } from './test-utils/host-bound-commands.ts'; import { writeGeneratedRemoteConfig } from '../cli/connection/generated-config.ts'; -import { - CLOUD_DEVICE_LEASE_TTL_MS, - PROXY_REMOTE_LEASE_TTL_MS, -} from '../cli/commands/connection-lease-policy.ts'; +import { PROXY_REMOTE_LEASE_TTL_MS } from '../cli/commands/connection-lease-policy.ts'; +import { CLOUD_DEVICE_LEASE_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'; diff --git a/src/cli/commands/connection-lease-policy.test.ts b/src/cli/commands/connection-lease-policy.test.ts index 9356e05b2f..711bd0af23 100644 --- a/src/cli/commands/connection-lease-policy.test.ts +++ b/src/cli/commands/connection-lease-policy.test.ts @@ -1,22 +1,50 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; +import { CLOUD_DEVICE_LEASE_TTL_MS } from '@agent-device/contracts/lease-scope'; import type { RemoteConnectionState } from '@agent-device/remote/remote-connection-state'; -import { - CLOUD_DEVICE_LEASE_TTL_MS, - connectionLeasePolicyForState, -} from './connection-lease-policy.ts'; +import { connectionLeasePolicyForState } from './connection-lease-policy.ts'; -function stateFor(leaseProvider: string): RemoteConnectionState { - return { leaseProvider } as RemoteConnectionState; -} +const LOCAL_PROVIDER: ConnectionProviderCapabilities = { + leaseKind: 'direct-device-provider', + requiresAppAttachment: false, + requiresRemoteDaemon: false, + supportsArtifacts: false, + supportsDeferredAppSelection: false, + supportsDirectPortReverse: false, + usesCloudWebDriverLease: false, +}; + +const providers: Record = { + '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 }, +}; -test.each(['limrun', 'aws-device-farm'])('a %s lease uses the cloud device TTL', (provider) => { - const state = stateFor(provider); +vi.mock('../connection/provider-policy.ts', () => ({ + connectionProviderCapabilities: (provider: string) => providers[provider], +})); - expect(connectionLeasePolicyForState(state).ttlMs(state)).toBe(CLOUD_DEVICE_LEASE_TTL_MS); -}); +function ttlFor(leaseProvider: string): number | undefined { + const state = { leaseProvider } as RemoteConnectionState; + return connectionLeasePolicyForState(state).ttlMs(state); +} -test('a lease for a provider without a cloud device keeps the daemon default TTL', () => { - const state = stateFor('webos'); +test.each(['cloud-device', 'cloud-device-deferred', 'cloud-webdriver'])( + 'a %s lease uses the cloud device TTL', + (provider) => { + expect(ttlFor(provider)).toBe(CLOUD_DEVICE_LEASE_TTL_MS); + }, +); - expect(connectionLeasePolicyForState(state).ttlMs(state)).toBeUndefined(); -}); +test.each(['local-device', 'malformed-flag'])( + 'a %s lease keeps the daemon default TTL', + (provider) => { + expect(ttlFor(provider)).toBeUndefined(); + }, +); diff --git a/src/cli/commands/connection-lease-policy.ts b/src/cli/commands/connection-lease-policy.ts index 638f0e50e5..4dfb41e5b4 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 { CLOUD_DEVICE_LEASE_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'; @@ -18,10 +19,11 @@ const leaseDeferredCommands = new Set([ 'session', ]); export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; -export const CLOUD_DEVICE_LEASE_TTL_MS = 10 * 60 * 1000; +/** A cloud device lease is one that declares `usesCloudWebDriverLease` or `holdsCloudDevice`. */ function cloudDeviceLeaseTtlMs(state: RemoteConnectionState): number | undefined { - return connectionProviderCapabilities(state.leaseProvider).holdsCloudDevice + const capabilities = connectionProviderCapabilities(state.leaseProvider); + return capabilities.usesCloudWebDriverLease || capabilities.holdsCloudDevice === true ? CLOUD_DEVICE_LEASE_TTL_MS : undefined; } @@ -96,6 +98,6 @@ const MACOS_APP_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { const CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => !leaseDeferredCommands.has(command), - ttlMs: () => CLOUD_DEVICE_LEASE_TTL_MS, + ttlMs: cloudDeviceLeaseTtlMs, resolveLeaseState: async (options) => ({ state: options.state }), }; diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index b2976ad7b6..8058b63120 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -53,7 +53,12 @@ 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`. -`LIMRUN_KEEP_ALIVE` only keeps the Limrun instance running. It does not renew the session lease. The daemon renews the lease on every command and ends the session after 10 minutes without one, so a pause longer than 10 minutes between commands needs a new `connect`. Without keep-alive, Limrun can end the instance during a pause of a few minutes, even while the lease is active. +Two timers can end an idle Limrun session. + +- The session lease. Each command that uses the lease renews it for 10 minutes. After 10 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. Limrun ends an instance after its own inactivity timeout. In a live run without keep-alive, a snapshot after 90 seconds of idle time succeeded, and a snapshot after 300 seconds failed with `WebSocket is not connected`, although the lease was still active. A pause longer than that timeout needs `LIMRUN_KEEP_ALIVE=1`. + +`LIMRUN_KEEP_ALIVE` keeps the instance running only. It does not renew the lease. ## Run a session from the CLI diff --git a/website/docs/docs/plugins.md b/website/docs/docs/plugins.md index 4b3cf7caa6..9a2c6aa161 100644 --- a/website/docs/docs/plugins.md +++ b/website/docs/docs/plugins.md @@ -128,7 +128,7 @@ connection: { }, ``` -`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. The CLI then renews that lease for 10 minutes on each command, not the 60-second daemon default. +`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 10-minute lease window, not the 60-second daemon default. A provider with `usesCloudWebDriverLease: true` gets the same window. A daemon with a lower `AGENT_DEVICE_LEASE_MAX_TTL_MS` grants its ceiling instead. ### Package for installation From 8e934665e74f1d51e2a9a62045097b1ee478d37d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 4 Nov 2026 19:11:43 +0100 Subject: [PATCH 05/10] docs(limrun): state the observed instance inactivity window Co-Authored-By: Claude --- website/docs/docs/limrun.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 8058b63120..21e9d1f957 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -56,7 +56,7 @@ Limrun ends an instance after its inactivity timeout, which an idle session can Two timers can end an idle Limrun session. - The session lease. Each command that uses the lease renews it for 10 minutes. After 10 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. Limrun ends an instance after its own inactivity timeout. In a live run without keep-alive, a snapshot after 90 seconds of idle time succeeded, and a snapshot after 300 seconds failed with `WebSocket is not connected`, although the lease was still active. A pause longer than that timeout needs `LIMRUN_KEEP_ALIVE=1`. +- The Limrun instance. Limrun ends an instance after its own inactivity timeout. In live runs without keep-alive, a snapshot after 150 seconds of idle time succeeded, and a snapshot after 210 seconds failed with `WebSocket is not connected`, although the lease was still active. A pause longer than that timeout needs `LIMRUN_KEEP_ALIVE=1`. `LIMRUN_KEEP_ALIVE` keeps the instance running only. It does not renew the lease. From da41a5a6c364312559d019d091aaf4ef29c75bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 06:10:11 +0100 Subject: [PATCH 06/10] fix(cli): share the five-minute remote device lease TTL with cloud device leases Co-Authored-By: Claude --- packages/contracts/src/lease-scope.ts | 7 ------- packages/contracts/src/provider-connection.ts | 4 ++-- .../__tests__/lease-registry-scope.test.ts | 7 ------- .../src/resources/lease-registry-scope.ts | 4 +--- src/__tests__/remote-connection.test.ts | 14 ++++++++------ .../commands/connection-lease-policy.test.ts | 17 ++++++++++++----- src/cli/commands/connection-lease-policy.ts | 15 +++++++-------- website/docs/docs/limrun.md | 4 ++-- website/docs/docs/plugins.md | 2 +- 9 files changed, 33 insertions(+), 41 deletions(-) diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 6675d541bd..2e71dc5fca 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -483,13 +483,6 @@ 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 default ceiling for a lease TTL a caller may request. */ -export const MAX_LEASE_TTL_MS = 10 * 60_000; -/** - * The TTL a cloud device lease requests so a session survives an agent's think time. A daemon whose - * ceiling is lower than this grants its ceiling instead of refusing the request. - */ -export const CLOUD_DEVICE_LEASE_TTL_MS = MAX_LEASE_TTL_MS; /** * 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 114dd05483..dfe2fa16d6 100644 --- a/packages/contracts/src/provider-connection.ts +++ b/packages/contracts/src/provider-connection.ts @@ -27,8 +27,8 @@ export type ConnectionProviderCapabilities = { supportsDirectPortReverse: boolean; usesCloudWebDriverLease: boolean; /** - * The provider leases a cloud-hosted device that bills while held. A lease with this capability or - * `usesCloudWebDriverLease` requests the cloud device lease TTL on each lease-using command. + * The provider leases a cloud-hosted device that bills while held. Its lease requests the same + * idle window as a proxy lease on each lease-using command, not the daemon default. */ holdsCloudDevice?: boolean; }; diff --git a/packages/daemon/src/resources/__tests__/lease-registry-scope.test.ts b/packages/daemon/src/resources/__tests__/lease-registry-scope.test.ts index bec87250e6..a9d046adce 100644 --- a/packages/daemon/src/resources/__tests__/lease-registry-scope.test.ts +++ b/packages/daemon/src/resources/__tests__/lease-registry-scope.test.ts @@ -1,6 +1,5 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { CLOUD_DEVICE_LEASE_TTL_MS } from '@agent-device/contracts/lease-scope'; import { createLeaseTtlResolver, leaseDeviceBindingKey, @@ -50,9 +49,3 @@ test('lease TTL normalization retains defaults, limits, and invalid configuratio assert.equal(configured(1), 1); assert.throws(() => configured(2), { code: 'INVALID_ARGS' }); }); - -test('a lowered ceiling grants the cloud device TTL its ceiling and still refuses other excess', () => { - const lowered = createLeaseTtlResolver({ maxLeaseTtlMs: 300_000 }); - assert.equal(lowered(CLOUD_DEVICE_LEASE_TTL_MS), 300_000); - assert.throws(() => lowered(300_001), { code: 'INVALID_ARGS' }); -}); diff --git a/packages/daemon/src/resources/lease-registry-scope.ts b/packages/daemon/src/resources/lease-registry-scope.ts index e13ebd82b5..4f9c101c46 100644 --- a/packages/daemon/src/resources/lease-registry-scope.ts +++ b/packages/daemon/src/resources/lease-registry-scope.ts @@ -1,8 +1,6 @@ import crypto from 'node:crypto'; import type { DeviceLease } from '@agent-device/contracts/device'; import { - CLOUD_DEVICE_LEASE_TTL_MS, - MAX_LEASE_TTL_MS, MIN_LEASE_WINDOW_MS, leaseScopeToReleaseRequest, } from '@agent-device/contracts/lease-scope'; @@ -100,6 +98,7 @@ export type NormalizedAllocateLeaseRequest = { }; const DEFAULT_LEASE_TTL_MS = 60_000; +const MAX_LEASE_TTL_MS = 10 * 60_000; const DEFAULT_LEASE_PROVIDER = 'default'; export function createLeaseTtlResolver(options: LeaseRegistryOptions) { @@ -115,7 +114,6 @@ export function createLeaseTtlResolver(options: LeaseRegistryOptions) { return (raw: number | undefined): number => { if (!Number.isInteger(raw)) return defaultTtl; const value = Number(raw); - if (value === CLOUD_DEVICE_LEASE_TTL_MS && value > maxTtl) return maxTtl; if (value < minTtl || value > maxTtl) { throw new AppError('INVALID_ARGS', `Lease ttlMs must be between ${minTtl} and ${maxTtl}.`); } diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index e039953420..b7907cbce2 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -28,8 +28,10 @@ import { materializeRemoteConnectionForCommand, } from './test-utils/host-bound-commands.ts'; import { writeGeneratedRemoteConfig } from '../cli/connection/generated-config.ts'; -import { PROXY_REMOTE_LEASE_TTL_MS } from '../cli/commands/connection-lease-policy.ts'; -import { CLOUD_DEVICE_LEASE_TTL_MS } from '@agent-device/contracts/lease-scope'; +import { + CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, + REMOTE_DEVICE_LEASE_TTL_MS, +} from '../cli/commands/connection-lease-policy.ts'; import { hasDeferredMetroConfig } from '../cli/commands/connection-metro-runtime.ts'; import { stopMetroTunnel } from '@agent-device/react-native'; import { AppError } from '@agent-device/kernel/errors'; @@ -627,7 +629,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_DEVICE_LEASE_TTL_MS); assert.equal(allocate.request?.leaseBackend, 'ios-instance'); assert.equal(materialized.flags.leaseId, 'abc123abc123abc1'); assert.equal(materialized.flags.udid, 'SIM-001'); @@ -681,7 +683,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_DEVICE_LEASE_TTL_MS); assert.equal(allocate.request?.leaseBackend, 'android-instance'); assert.equal(materialized.flags.leaseId, 'android-lease-1'); assert.equal(materialized.flags.serial, 'emulator-5554'); @@ -781,7 +783,7 @@ test('cloud webdriver connection allocates and heartbeats with extended lease TT }); assert.equal(allocateRequest?.leaseProvider, 'aws-device-farm'); - assert.equal(allocateRequest?.ttlMs, CLOUD_DEVICE_LEASE_TTL_MS); + assert.equal(allocateRequest?.ttlMs, CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS); assert.equal(materialized.flags.leaseId, 'cloud-lease-1'); let heartbeatRequest: Parameters[0] | undefined; @@ -803,7 +805,7 @@ test('cloud webdriver connection allocates and heartbeats with extended lease TT }); assert.equal(heartbeatRequest?.leaseId, 'cloud-lease-1'); - assert.equal(heartbeatRequest?.ttlMs, CLOUD_DEVICE_LEASE_TTL_MS); + assert.equal(heartbeatRequest?.ttlMs, CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS); fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/cli/commands/connection-lease-policy.test.ts b/src/cli/commands/connection-lease-policy.test.ts index 711bd0af23..9ba336482c 100644 --- a/src/cli/commands/connection-lease-policy.test.ts +++ b/src/cli/commands/connection-lease-policy.test.ts @@ -1,8 +1,11 @@ import { expect, test, vi } from 'vitest'; import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; -import { CLOUD_DEVICE_LEASE_TTL_MS } from '@agent-device/contracts/lease-scope'; import type { RemoteConnectionState } from '@agent-device/remote/remote-connection-state'; -import { connectionLeasePolicyForState } from './connection-lease-policy.ts'; +import { + CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, + REMOTE_DEVICE_LEASE_TTL_MS, + connectionLeasePolicyForState, +} from './connection-lease-policy.ts'; const LOCAL_PROVIDER: ConnectionProviderCapabilities = { leaseKind: 'direct-device-provider', @@ -35,13 +38,17 @@ function ttlFor(leaseProvider: string): number | undefined { return connectionLeasePolicyForState(state).ttlMs(state); } -test.each(['cloud-device', 'cloud-device-deferred', 'cloud-webdriver'])( - 'a %s lease uses the cloud device TTL', +test.each(['cloud-device', 'cloud-device-deferred'])( + 'a %s lease uses the remote device TTL', (provider) => { - expect(ttlFor(provider)).toBe(CLOUD_DEVICE_LEASE_TTL_MS); + expect(ttlFor(provider)).toBe(REMOTE_DEVICE_LEASE_TTL_MS); }, ); +test('a cloud WebDriver lease keeps its own TTL', () => { + expect(ttlFor('cloud-webdriver')).toBe(CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS); +}); + test.each(['local-device', 'malformed-flag'])( 'a %s lease keeps the daemon default TTL', (provider) => { diff --git a/src/cli/commands/connection-lease-policy.ts b/src/cli/commands/connection-lease-policy.ts index 4dfb41e5b4..23abcc670f 100644 --- a/src/cli/commands/connection-lease-policy.ts +++ b/src/cli/commands/connection-lease-policy.ts @@ -1,6 +1,5 @@ import crypto from 'node:crypto'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; -import { CLOUD_DEVICE_LEASE_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'; @@ -18,13 +17,13 @@ const leaseDeferredCommands = new Set([ 'metro', 'session', ]); -export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; +/** The idle window of a remote device lease: a proxy lease or a provider that `holdsCloudDevice`. */ +export const REMOTE_DEVICE_LEASE_TTL_MS = 5 * 60 * 1000; +export const CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS = 10 * 60 * 1000; -/** A cloud device lease is one that declares `usesCloudWebDriverLease` or `holdsCloudDevice`. */ function cloudDeviceLeaseTtlMs(state: RemoteConnectionState): number | undefined { - const capabilities = connectionProviderCapabilities(state.leaseProvider); - return capabilities.usesCloudWebDriverLease || capabilities.holdsCloudDevice === true - ? CLOUD_DEVICE_LEASE_TTL_MS + return connectionProviderCapabilities(state.leaseProvider).holdsCloudDevice === true + ? REMOTE_DEVICE_LEASE_TTL_MS : undefined; } @@ -72,7 +71,7 @@ const DEFAULT_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { const PROXY_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => command !== 'devices' && !leaseDeferredCommands.has(command), - ttlMs: () => PROXY_REMOTE_LEASE_TTL_MS, + ttlMs: () => REMOTE_DEVICE_LEASE_TTL_MS, // 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, @@ -98,6 +97,6 @@ const MACOS_APP_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { const CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => !leaseDeferredCommands.has(command), - ttlMs: cloudDeviceLeaseTtlMs, + ttlMs: () => CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, resolveLeaseState: async (options) => ({ state: options.state }), }; diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 21e9d1f957..468ebbb56a 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -55,8 +55,8 @@ Limrun ends an instance after its inactivity timeout, which an idle session can Two timers can end an idle Limrun session. -- The session lease. Each command that uses the lease renews it for 10 minutes. After 10 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. Limrun ends an instance after its own inactivity timeout. In live runs without keep-alive, a snapshot after 150 seconds of idle time succeeded, and a snapshot after 210 seconds failed with `WebSocket is not connected`, although the lease was still active. A pause longer than that timeout needs `LIMRUN_KEEP_ALIVE=1`. +- 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. Limrun ends an instance after its own inactivity timeout, between about 150 and 210 seconds in live runs. In those runs without keep-alive, a snapshot after 150 seconds of idle time succeeded, and a snapshot after 210 seconds failed with `WebSocket is not connected`, although the lease was still active. A pause longer than that timeout needs `LIMRUN_KEEP_ALIVE=1`. `LIMRUN_KEEP_ALIVE` keeps the instance running only. It does not renew the lease. diff --git a/website/docs/docs/plugins.md b/website/docs/docs/plugins.md index 9a2c6aa161..58969011cd 100644 --- a/website/docs/docs/plugins.md +++ b/website/docs/docs/plugins.md @@ -128,7 +128,7 @@ connection: { }, ``` -`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 10-minute lease window, not the 60-second daemon default. A provider with `usesCloudWebDriverLease: true` gets the same window. A daemon with a lower `AGENT_DEVICE_LEASE_MAX_TTL_MS` grants its ceiling instead. +`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 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 From 884a495db628eb6a6b097e92836729a454ea9ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 06:17:53 +0100 Subject: [PATCH 07/10] refactor(cli): one five-minute idle TTL for every remote device lease Co-Authored-By: Claude --- docs/adr/0007-remote-device-leases.md | 2 +- packages/contracts/src/provider-connection.ts | 3 +- src/__tests__/remote-connection.test.ts | 13 ++++----- .../commands/connection-lease-policy.test.ts | 29 +++++++------------ src/cli/commands/connection-lease-policy.ts | 29 +++++++------------ website/docs/docs/plugins.md | 2 +- 6 files changed, 31 insertions(+), 47 deletions(-) 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/provider-connection.ts b/packages/contracts/src/provider-connection.ts index dfe2fa16d6..a312be83db 100644 --- a/packages/contracts/src/provider-connection.ts +++ b/packages/contracts/src/provider-connection.ts @@ -28,7 +28,8 @@ export type ConnectionProviderCapabilities = { usesCloudWebDriverLease: boolean; /** * The provider leases a cloud-hosted device that bills while held. Its lease requests the same - * idle window as a proxy lease on each lease-using command, not the daemon default. + * idle window as a proxy or cloud WebDriver lease on each lease-using command, not the daemon + * default. */ holdsCloudDevice?: boolean; }; diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index b7907cbce2..ff913b54a1 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, - REMOTE_DEVICE_LEASE_TTL_MS, -} from '../cli/commands/connection-lease-policy.ts'; +import { REMOTE_LEASE_IDLE_TTL_MS } from '../cli/commands/connection-lease-policy.ts'; 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, REMOTE_DEVICE_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, REMOTE_DEVICE_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/cli/commands/connection-lease-policy.test.ts b/src/cli/commands/connection-lease-policy.test.ts index 9ba336482c..0c70199d83 100644 --- a/src/cli/commands/connection-lease-policy.test.ts +++ b/src/cli/commands/connection-lease-policy.test.ts @@ -2,8 +2,7 @@ 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 { - CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, - REMOTE_DEVICE_LEASE_TTL_MS, + REMOTE_LEASE_IDLE_TTL_MS, connectionLeasePolicyForState, } from './connection-lease-policy.ts'; @@ -18,6 +17,7 @@ const LOCAL_PROVIDER: ConnectionProviderCapabilities = { }; const providers: Record = { + proxy: { ...LOCAL_PROVIDER, leaseKind: 'proxy', requiresRemoteDaemon: true }, 'cloud-device': { ...LOCAL_PROVIDER, holdsCloudDevice: true }, 'cloud-device-deferred': { ...LOCAL_PROVIDER, @@ -38,20 +38,13 @@ function ttlFor(leaseProvider: string): number | undefined { return connectionLeasePolicyForState(state).ttlMs(state); } -test.each(['cloud-device', 'cloud-device-deferred'])( - 'a %s lease uses the remote device TTL', - (provider) => { - expect(ttlFor(provider)).toBe(REMOTE_DEVICE_LEASE_TTL_MS); - }, -); - -test('a cloud WebDriver lease keeps its own TTL', () => { - expect(ttlFor('cloud-webdriver')).toBe(CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS); +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); }); - -test.each(['local-device', 'malformed-flag'])( - 'a %s lease keeps the daemon default TTL', - (provider) => { - expect(ttlFor(provider)).toBeUndefined(); - }, -); diff --git a/src/cli/commands/connection-lease-policy.ts b/src/cli/commands/connection-lease-policy.ts index 23abcc670f..1255e40522 100644 --- a/src/cli/commands/connection-lease-policy.ts +++ b/src/cli/commands/connection-lease-policy.ts @@ -17,13 +17,15 @@ const leaseDeferredCommands = new Set([ 'metro', 'session', ]); -/** The idle window of a remote device lease: a proxy lease or a provider that `holdsCloudDevice`. */ -export const REMOTE_DEVICE_LEASE_TTL_MS = 5 * 60 * 1000; -export const CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS = 10 * 60 * 1000; +/** The idle window of every remote device lease: proxy, cloud device and cloud WebDriver. */ +export const REMOTE_LEASE_IDLE_TTL_MS = 5 * 60 * 1000; -function cloudDeviceLeaseTtlMs(state: RemoteConnectionState): number | undefined { - return connectionProviderCapabilities(state.leaseProvider).holdsCloudDevice === true - ? REMOTE_DEVICE_LEASE_TTL_MS +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; } @@ -50,28 +52,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: cloudDeviceLeaseTtlMs, + ttlMs: remoteLeaseTtlMs, resolveLeaseState: async (options) => ({ state: options.state }), }; const DEFAULT_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => !leaseDeferredCommands.has(command), - ttlMs: cloudDeviceLeaseTtlMs, + ttlMs: remoteLeaseTtlMs, resolveLeaseState: async (options) => ({ state: options.state }), }; const PROXY_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => command !== 'devices' && !leaseDeferredCommands.has(command), - ttlMs: () => REMOTE_DEVICE_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, @@ -94,9 +93,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/website/docs/docs/plugins.md b/website/docs/docs/plugins.md index 58969011cd..8c3043f40b 100644 --- a/website/docs/docs/plugins.md +++ b/website/docs/docs/plugins.md @@ -128,7 +128,7 @@ connection: { }, ``` -`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 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`. +`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 From 0cf20883a2a2c5d0d1b67687e824256eec33ae7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 06:31:45 +0100 Subject: [PATCH 08/10] docs(limrun): state the three minute instance inactivity timeout Co-Authored-By: Claude --- website/docs/docs/limrun.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 468ebbb56a..e60ff7cff2 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -56,7 +56,7 @@ Limrun ends an instance after its inactivity timeout, which an idle session can 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. Limrun ends an instance after its own inactivity timeout, between about 150 and 210 seconds in live runs. In those runs without keep-alive, a snapshot after 150 seconds of idle time succeeded, and a snapshot after 210 seconds failed with `WebSocket is not connected`, although the lease was still active. A pause longer than that timeout needs `LIMRUN_KEEP_ALIVE=1`. +- The Limrun instance. Limrun ends an instance after 3 minutes without activity (the inactivity timeout of an instance that agent-device creates). It ends the instance even while the lease is active, and every later command then fails with `WebSocket is not connected` until the lease expires or you run `disconnect`. A pause longer than 3 minutes needs `LIMRUN_KEEP_ALIVE=1`. `LIMRUN_KEEP_ALIVE` keeps the instance running only. It does not renew the lease. From 354fbf43be71daf924b71d28ef3895112a688d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 06:43:30 +0100 Subject: [PATCH 09/10] fix(limrun): create instances with the remote lease idle window as inactivity timeout Limrun's API default is 3 minutes, so a created instance ended before its lease. Co-Authored-By: Claude --- packages/contracts/src/lease-scope.ts | 2 ++ .../src/session-allocation.test.ts | 23 +++++++++++++++++++ .../provider-limrun/src/session-allocation.ts | 6 +++++ src/__tests__/remote-connection.test.ts | 2 +- .../commands/connection-lease-policy.test.ts | 6 ++--- src/cli/commands/connection-lease-policy.ts | 4 +--- website/docs/docs/limrun.md | 4 ++-- 7 files changed, 37 insertions(+), 10 deletions(-) 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/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/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index ff913b54a1..9f9bda97b0 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -28,7 +28,7 @@ import { materializeRemoteConnectionForCommand, } from './test-utils/host-bound-commands.ts'; import { writeGeneratedRemoteConfig } from '../cli/connection/generated-config.ts'; -import { REMOTE_LEASE_IDLE_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'; diff --git a/src/cli/commands/connection-lease-policy.test.ts b/src/cli/commands/connection-lease-policy.test.ts index 0c70199d83..825f1e41bf 100644 --- a/src/cli/commands/connection-lease-policy.test.ts +++ b/src/cli/commands/connection-lease-policy.test.ts @@ -1,10 +1,8 @@ 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, - connectionLeasePolicyForState, -} from './connection-lease-policy.ts'; +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', diff --git a/src/cli/commands/connection-lease-policy.ts b/src/cli/commands/connection-lease-policy.ts index 1255e40522..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,9 +18,6 @@ const leaseDeferredCommands = new Set([ 'metro', 'session', ]); -/** The idle window of every remote device lease: proxy, cloud device and cloud WebDriver. */ -export const REMOTE_LEASE_IDLE_TTL_MS = 5 * 60 * 1000; - function remoteLeaseTtlMs(state: RemoteConnectionState): number | undefined { const capabilities = connectionProviderCapabilities(state.leaseProvider); return capabilities.leaseKind === 'proxy' || diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index e60ff7cff2..5f4114574e 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -56,9 +56,9 @@ Limrun ends an instance after its inactivity timeout, which an idle session can 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. Limrun ends an instance after 3 minutes without activity (the inactivity timeout of an instance that agent-device creates). It ends the instance even while the lease is active, and every later command then fails with `WebSocket is not connected` until the lease expires or you run `disconnect`. A pause longer than 3 minutes needs `LIMRUN_KEEP_ALIVE=1`. +- 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 only. It does not renew the lease. +`LIMRUN_KEEP_ALIVE` keeps the instance running past five minutes. It does not renew the lease. ## Run a session from the CLI From ec358341f19eb7b5fd8b5c77fea039eb882efacf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 06:50:14 +0100 Subject: [PATCH 10/10] test(limrun): expect the inactivity timeout in the Android create input Co-Authored-By: Claude --- packages/provider-limrun/src/runtime-dependencies.test.ts | 1 + 1 file changed, 1 insertion(+) 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',