diff --git a/packages/capture-kit/src/durable-resource-envelope.ts b/packages/capture-kit/src/durable-resource-envelope.ts index b7629b3cf4..979672d6b1 100644 --- a/packages/capture-kit/src/durable-resource-envelope.ts +++ b/packages/capture-kit/src/durable-resource-envelope.ts @@ -1,5 +1,6 @@ import { isAppleOs, + isDeviceTarget, isPlatform, type AppleOS, type DeviceIdentity, @@ -233,10 +234,6 @@ function isDeviceKind(value: unknown): value is DeviceKind { return value === 'simulator' || value === 'emulator' || value === 'device'; } -function isDeviceTarget(value: unknown): value is DeviceTarget { - return value === 'mobile' || value === 'tv' || value === 'desktop'; -} - function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } diff --git a/packages/daemon/src/resources/__tests__/lease-registry.test.ts b/packages/daemon/src/resources/__tests__/lease-registry.test.ts index 38ac0eca0b..1e3e96314e 100644 --- a/packages/daemon/src/resources/__tests__/lease-registry.test.ts +++ b/packages/daemon/src/resources/__tests__/lease-registry.test.ts @@ -75,6 +75,87 @@ test('allocateLease keeps a caller-chosen lease id and refuses any id it already } }); +test('an allocation repeating its own live lease id gets that lease back, on any device it names or none', () => { + const registry = new LeaseRegistry(); + const leaseId = 'abcdef0123456789abcdef0123456789'; + const owner = { + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + clientId: 'client-1', + } as const; + const lease = registry.allocateLease({ ...owner, leaseId, deviceKey: 'ios:mobile:sim-1' }); + + assert.equal(registry.renewRequestedLease({ ...owner, leaseId })?.leaseId, leaseId); + assert.equal(registry.allocateLease({ ...owner, leaseId }).deviceKey, lease.deviceKey); + assert.equal( + registry.renewRequestedLease({ ...owner, leaseId, deviceKey: 'ios:mobile:sim-2' }), + undefined, + ); + assert.equal( + registry.renewRequestedLease({ ...owner, leaseId, clientId: 'client-2' }), + undefined, + ); + assert.equal(registry.renewRequestedLease({ ...owner, leaseId, runId: 'run-2' }), undefined); + assert.equal(registry.renewRequestedLease(owner), undefined); + assert.throws( + () => registry.allocateLease({ ...owner, leaseId, deviceKey: 'ios:mobile:sim-2' }), + isLeaseIdInUse, + ); + assert.equal(registry.listActiveLeases().length, 1); +}); + +test('a retried allocation renews its own lease for the window that lease carries', () => { + let now = 1_000; + const registry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 10_000 }); + const leaseId = 'abcdef0123456789abcdef0123456789'; + const owner = { tenantId: 'tenant-a', runId: 'run-1', clientId: 'client-1' } as const; + const lease = registry.allocateLease({ ...owner, leaseId, ttlMs: 60_000 }); + + now = 31_000; + const retried = registry.renewRequestedLease({ ...owner, leaseId }); + + assert.equal(lease.expiresAt, 61_000); + assert.equal(retried?.heartbeatAt, 31_000); + assert.equal(retried?.expiresAt, 91_000); +}); + +test('an allocation naming an issued lease id gets exactly that lease or LEASE_ID_IN_USE, never another lease of its run', () => { + const registry = new LeaseRegistry(); + const owner = { tenantId: 'tenant-a', runId: 'run-1', clientId: 'client-1' } as const; + const first = registry.allocateLease({ + ...owner, + leaseId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + deviceKey: 'ios:mobile:sim-1', + }); + const second = registry.allocateLease({ + ...owner, + leaseId: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + deviceKey: 'ios:mobile:sim-2', + }); + + assert.notEqual(first.leaseId, second.leaseId); + assert.throws( + () => registry.allocateLease({ ...owner, leaseId: first.leaseId, deviceKey: second.deviceKey }), + isLeaseIdInUse, + ); +}); + +test('a retried allocation of a host-pinned lease never outlives the host window', () => { + let now = 1_000; + const registry = new LeaseRegistry({ now: () => now }); + const leaseId = 'cccccccccccccccccccccccccccccccc'; + const owner = { tenantId: 'tenant-a', runId: 'run-1' } as const; + registry.putHostLease(leaseId, { ...owner, ttlMs: 10_000 }); + + now = 5_000; + const retried = registry.allocateLease({ ...owner, leaseId, ttlMs: 600_000 }); + + assert.equal(retried.leaseId, leaseId); + assert.equal(retried.expiresAt, 15_000); +}); + test('an expired provider lease keeps its id and provider session from a caller-chosen reuse', () => { let now = 1_000; const registry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 10_000 }); diff --git a/packages/daemon/src/resources/lease-registry.ts b/packages/daemon/src/resources/lease-registry.ts index b1c2444339..8fa79ef869 100644 --- a/packages/daemon/src/resources/lease-registry.ts +++ b/packages/daemon/src/resources/lease-registry.ts @@ -79,19 +79,17 @@ export class LeaseRegistry { } allocateLease(request: AllocateLeaseRequest): DeviceLease { - const chosenLeaseId = - request.leaseId === undefined ? undefined : normalizeRequiredLeaseId(request.leaseId); - const normalized = normalizeAllocateLeaseRequest(request); - this.cleanupExpiredLeases(); - const leaseTtlMs = this.resolveLeaseTtlMs(normalized.ttlMs); - this.assertHumanControlAdmission(normalized); - const existingLease = this.refreshExistingRunBinding(normalized, leaseTtlMs); - if (existingLease) return existingLease; + const { chosenLeaseId, normalized, leaseTtlMs } = this.prepareAllocation(request); + const requestedLease = this.refreshRequestedLease(chosenLeaseId, normalized, leaseTtlMs); + if (requestedLease) return requestedLease; if (chosenLeaseId && this.issuedLeaseIds.has(chosenLeaseId)) { throw new AppError('INVALID_ARGS', 'Lease id already names a lease this daemon issued.', { reason: 'LEASE_ID_IN_USE', }); } + this.assertHumanControlAdmission(normalized); + const existingLease = this.refreshExistingRunBinding(normalized, leaseTtlMs); + if (existingLease) return existingLease; this.assertDeviceAvailable(normalized); this.enforceCapacity(normalized.backend); const lease = createDeviceLease(normalized, leaseTtlMs, this.now(), chosenLeaseId); @@ -134,6 +132,45 @@ export class LeaseRegistry { return { ...lease }; } + /** + * The live lease an allocation gets back when it repeats the `leaseId` it named before, for the + * same owner: a retry after a lost reply receives the lease it was issued, not a second one. A + * request naming no device matches whichever device the lease binds, and one naming no `ttlMs` + * keeps the window the lease already carries. + */ + renewRequestedLease(request: AllocateLeaseRequest): DeviceLease | undefined { + const { chosenLeaseId, normalized, leaseTtlMs } = this.prepareAllocation(request); + return this.refreshRequestedLease(chosenLeaseId, normalized, leaseTtlMs); + } + + private prepareAllocation(request: AllocateLeaseRequest): { + chosenLeaseId?: string; + normalized: NormalizedAllocateLeaseRequest; + leaseTtlMs: number; + } { + const chosenLeaseId = + request.leaseId === undefined ? undefined : normalizeRequiredLeaseId(request.leaseId); + const normalized = normalizeAllocateLeaseRequest(request); + this.cleanupExpiredLeases(); + return { chosenLeaseId, normalized, leaseTtlMs: this.resolveLeaseTtlMs(normalized.ttlMs) }; + } + + private refreshRequestedLease( + chosenLeaseId: string | undefined, + request: NormalizedAllocateLeaseRequest, + leaseTtlMs: number, + ): DeviceLease | undefined { + const lease = chosenLeaseId === undefined ? undefined : this.leases.get(chosenLeaseId); + if (!lease) return undefined; + const scope = { ...request, deviceKey: request.deviceKey ?? lease.deviceKey }; + if (!hasAllocatedScope(lease, scope)) return undefined; + this.assertHumanControlAdmission(lease); + return this.refreshLease( + request.retainOnClose ? { ...lease, retainOnClose: true } : lease, + request.ttlMs === undefined ? leaseOwnTtlMs(lease) : leaseTtlMs, + ); + } + private issueLease(lease: DeviceLease): void { this.issuedLeaseIds.add(lease.leaseId); this.leases.set(lease.leaseId, lease); @@ -180,11 +217,7 @@ export class LeaseRegistry { assertLeaseScopeMatch(lease, request); const requestedTtlMs = request.ttlMs === undefined ? leaseOwnTtlMs(lease) : this.resolveLeaseTtlMs(request.ttlMs); - const hostTtlMs = this.hostTtlMsByLeaseId.get(leaseId); - return this.refreshLease( - lease, - hostTtlMs === undefined ? requestedTtlMs : Math.min(requestedTtlMs, hostTtlMs), - ); + return this.refreshLease(lease, requestedTtlMs); } /** @@ -584,11 +617,13 @@ export class LeaseRegistry { }); } + /** Every renewal route ends here, so a host-pinned lease never outlives the host's window. */ private refreshLease(lease: DeviceLease, ttlMs: number, now = this.now()): DeviceLease { + const hostTtlMs = this.hostTtlMsByLeaseId.get(lease.leaseId); const updated: DeviceLease = { ...lease, heartbeatAt: now, - expiresAt: now + ttlMs, + expiresAt: now + (hostTtlMs === undefined ? ttlMs : Math.min(ttlMs, hostTtlMs)), }; this.leases.set(updated.leaseId, updated); this.bindLease(updated); diff --git a/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts b/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts index 4ade38ccb1..5b1a31f3ae 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts @@ -483,6 +483,7 @@ function proxyAllocateRequest( async function allocateAgainstInventory( request: DaemonRequest, devices: readonly DeviceInfo[], + leaseRegistry = new LeaseRegistry(), ): Promise<{ lease: DeviceLease; inventoryReads: number }> { let inventoryReads = 0; const response = await withTestDeviceInventory( @@ -497,11 +498,13 @@ async function allocateAgainstInventory( req: request, sessionName: request.session, sessionStore: makeSessionStore('agent-device-proxy-allocate-by-spec-'), - leaseRegistry: new LeaseRegistry(), + leaseRegistry, }), ); assert.ok(response?.ok); - return { lease: response.data?.lease as DeviceLease, inventoryReads }; + const lease = response.data?.lease as DeviceLease | undefined; + assert.ok(lease); + return { lease, inventoryReads }; } test('a proxy allocation that names no device picks one by its selectors and binds its deviceKey', async () => { @@ -548,3 +551,43 @@ test('an allocation that names its device, names no spec, or is not a proxy leas assert.equal(unspecified.lease.deviceKey, undefined); assert.equal(unspecified.inventoryReads, 0); }); + +test('a proxy allocation retried with its lease id gets its lease back without picking again', async () => { + const leaseId = 'abcdef0123456789abcdef0123456789'; + const registry = new LeaseRegistry(); + const request = proxyAllocateRequest({ platform: 'ios' }, { leaseId }); + const first = await allocateAgainstInventory(request, [IOS_SIMULATOR], registry); + assert.equal(first.lease.deviceKey, 'ios:mobile:sim-1'); + + const deviceGone = await allocateAgainstInventory(request, [], registry); + assert.equal(deviceGone.lease.leaseId, leaseId); + assert.equal(deviceGone.lease.deviceKey, 'ios:mobile:sim-1'); + assert.equal(deviceGone.inventoryReads, 0); + + const otherCandidate = await allocateAgainstInventory(request, [SECOND_IOS_SIMULATOR], registry); + assert.equal(otherCandidate.lease.leaseId, leaseId); + assert.equal(otherCandidate.inventoryReads, 0); + assert.deepEqual( + registry.listActiveLeases().map((lease) => lease.leaseId), + [leaseId], + ); +}); + +test("a proxy allocation naming another owner's lease id still picks and is refused", async () => { + const leaseId = 'abcdef0123456789abcdef0123456789'; + const registry = new LeaseRegistry(); + await allocateAgainstInventory( + proxyAllocateRequest({ platform: 'ios' }, { leaseId }), + [IOS_SIMULATOR], + registry, + ); + await assert.rejects( + allocateAgainstInventory( + proxyAllocateRequest({ platform: 'ios' }, { leaseId, clientId: 'client-2' }), + [SECOND_IOS_SIMULATOR], + registry, + ), + (error: AppError) => + error.code === 'INVALID_ARGS' && error.details?.reason === 'LEASE_ID_IN_USE', + ); +}); diff --git a/packages/daemon/src/sessions/handlers/lease.ts b/packages/daemon/src/sessions/handlers/lease.ts index 0775520caa..30cceedc5c 100644 --- a/packages/daemon/src/sessions/handlers/lease.ts +++ b/packages/daemon/src/sessions/handlers/lease.ts @@ -90,16 +90,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise entry.leaseId), - ); - const lease = leaseRegistry.allocateLease( - leaseScopeToAllocateRequest( - pickedDeviceKey ? { ...leaseScope, deviceKey: pickedDeviceKey } : leaseScope, - ), - ); - const reused = activeLeaseIds.has(lease.leaseId); + const { lease, reused } = await allocateOrRenewLease(req, leaseScope, leaseRegistry); const requestId = req.meta?.requestId; return await leaseRegistry.runDeviceMutation(lease, async () => { let providerData: Record | undefined; @@ -186,6 +177,26 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { + const request = leaseScopeToAllocateRequest(scope); + const renewed = leaseRegistry.renewRequestedLease(request); + if (renewed) return { lease: renewed, reused: true }; + const pickedDeviceKey = await pickProxyLeaseDeviceKey(req, scope); + const activeLeaseIds = new Set(leaseRegistry.listActiveLeases().map((entry) => entry.leaseId)); + const lease = leaseRegistry.allocateLease( + pickedDeviceKey ? { ...request, deviceKey: pickedDeviceKey } : request, + ); + return { lease, reused: activeLeaseIds.has(lease.leaseId) }; +} + /** * A proxy allocation that names no `deviceKey` but does name a device spec leaves the device to this * daemon: it resolves the request's selectors as a command would, and the lease binds that device. diff --git a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts index 5727e7192d..fe09faa0a7 100644 --- a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts +++ b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts @@ -263,6 +263,40 @@ test('a daemon whose lease names no device gets that lease released and the devi fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('a renewed lease that names no device is refused and left to its owner', async () => { + const { tempRoot, stateDir, flags } = proxyWorkspace('agent-device-proxy-by-spec-keyless-', { + leaseId: 'lease-1', + }); + + await assert.rejects( + materializeRemoteConnectionForCommand({ + command: 'open', + flags: { ...flags, device: 'iPhone 17' }, + client: createTestClient({ + listDevices: async () => [IPHONE_SIMULATOR], + allocate: unexpectedCommandCall, + release: unexpectedCommandCall, + heartbeat: async (request) => ({ + leaseId: request.leaseId, + tenantId: 'proxy', + runId: 'proxy-client-1', + backend: 'ios-instance', + leaseProvider: 'proxy', + clientId: 'client-1', + }), + }), + }), + (error: AppError) => + error.code === 'UNAUTHORIZED' && + error.details?.reason === 'LEASE_SCOPE_MISMATCH' && + error.details?.leaseId === 'lease-1', + ); + const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); + assert.equal(state?.leaseId, 'lease-1'); + assert.equal(state?.deviceKey, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('proxy open with an exact udid still picks locally and allocates that device once', async () => { const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-by-spec-exact-'); const requests: LeaseAllocateRequest[] = []; diff --git a/src/__tests__/remote-connection.fixtures.ts b/src/__tests__/remote-connection.fixtures.ts index d243921142..22f0207256 100644 --- a/src/__tests__/remote-connection.fixtures.ts +++ b/src/__tests__/remote-connection.fixtures.ts @@ -104,6 +104,8 @@ export function createTestClient( tenantId: request.tenant ?? 'acme', runId: request.runId ?? 'run-123', backend: request.leaseBackend ?? 'android-instance', + // A daemon renews only a lease whose device matches the key the heartbeat names. + ...(request.deviceKey ? { deviceKey: request.deviceKey } : {}), })), release: options.release ?? (async () => ({ released: true })), }), diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index 14776a7216..28494cbcee 100644 --- a/src/cli/commands/proxy-lease-device.ts +++ b/src/cli/commands/proxy-lease-device.ts @@ -82,8 +82,10 @@ async function resolveProxyLeaseStateLocally(options: { /** * Takes the device from a lease allocated by spec: the one its `deviceKey` names, whether the daemon - * just picked it or the lease is renewed. A daemon that predates picking leaves the key unset, so its - * lease is released and the device is picked here and allocated by its key, as before. + * just picked it or the lease is renewed. A daemon that predates picking leaves the key of a new + * lease unset, so that lease is released and the device is picked here and allocated by its key, as + * before. Every lease this client allocates therefore names its device; a renewed lease that names + * none was supplied from outside, so it is refused and never released. */ export async function bindLeaseNamedDevice(options: { client: AgentDeviceClient; @@ -109,25 +111,36 @@ export async function bindLeaseNamedDevice(options: { materialized, }; } - if (acquired) { - await client.leases.release({ - tenant: options.state.tenant, - runId: options.state.runId, - leaseId: lease.leaseId, - leaseBackend: lease.backend, - ...mergeRemoteConnectionRequestMetadata(lease, options.state), - deviceKey: lease.deviceKey, - }); - } + if (!acquired) throw unboundLeaseError(options.state, lease); + await client.leases.release({ + tenant: options.state.tenant, + runId: options.state.runId, + leaseId: lease.leaseId, + leaseBackend: lease.backend, + ...mergeRemoteConnectionRequestMetadata(lease, options.state), + deviceKey: lease.deviceKey, + }); const resolved = await resolveProxyLeaseStateLocally(options); return { state: resolved.state, - materialized: acquired - ? await options.allocateByDeviceKey(resolved.state, flags) - : materialized, + materialized: await options.allocateByDeviceKey(resolved.state, flags), }; } +function unboundLeaseError(state: RemoteConnectionState, lease: Lease): AppError { + return new AppError( + 'UNAUTHORIZED', + 'The active proxy lease names no device this client can use.', + { + session: state.session, + leaseId: lease.leaseId, + deviceKey: lease.deviceKey, + reason: 'LEASE_SCOPE_MISMATCH', + hint: 'Use a lease that names its device, or drop the lease id from the remote config and flags so open allocates one.', + }, + ); +} + /** * A renewed lease serves a request only when its device matches every selector the request names. * Platform and target are already checked against the connection record; the device name and the diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 01d48f5ea0..41dcb69e9f 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -247,7 +247,7 @@ Domain client methods: - `client.sessions.list()`, `stateDir()`, `close()`, `saveScript()`, `artifacts()` - `client.apps.install()`, `reinstall()`, `installFromSource()`, `list()`, `open()`, `close()`, `push()`, `triggerEvent()` - `client.materializations.release()` -- `client.leases.allocate()`, `heartbeat()`, `release()`. Pass `retainOnClose: true` to `allocate()` when you release the lease yourself; session `close` then leaves it active until `release()`, expiry, or daemon shutdown. The returned lease has `retainOnClose: true` only when the daemon honored it. Keep heartbeating a retained lease between sessions: it expires after its `ttlMs` like any other lease. Pass `leaseId` (16 to 128 hex characters) to `allocate()` to name a new lease with an id the daemon has never issued, then use the returned `leaseId`: a run that already holds a lease gets that one back, and an older daemon mints its own. +- `client.leases.allocate()`, `heartbeat()`, `release()`. Pass `retainOnClose: true` to `allocate()` when you release the lease yourself; session `close` then leaves it active until `release()`, expiry, or daemon shutdown. The returned lease has `retainOnClose: true` only when the daemon honored it. Keep heartbeating a retained lease between sessions: it expires after its `ttlMs` like any other lease. Pass `leaseId` (16 to 128 hex characters) to `allocate()` to name a new lease with an id the daemon has never issued, or to get back your live lease with that id when you retry with the same tenant, run, client, lease backend, and provider (and no device, or the leased one), then use the returned `leaseId`: a run that already holds a lease gets that one back, and an older daemon mints its own. - `client.metro.prepare()`, `reload()` - `client.capture.snapshot()`, `screenshot()`, `diff()` - `client.interactions.click()`, `press()`, `longPress()`, `swipe()`, `pan()`, `drag()`, `fling()`, `swipeGesture()`, `focus()`, `type()`, `fill()`, `scroll()`, `pinch()`, `rotateGesture()`, `transformGesture()`, `get()`, `is()`, `find()` diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index a8122a47ad..2554de4da6 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -47,9 +47,9 @@ agent-device disconnect `connect proxy` stores the proxy profile and client identity. `open` leases the device for you, and the lease expires after five minutes without commands. A lease allocated directly over the RPC without `ttlMs` gets the daemon's one-minute inactivity default instead. Either window starts when allocation completes, not when it was requested. `close` releases the active session and its device lease, unless the lease was allocated with `retainOnClose`; only `leases.release`, expiry, or daemon shutdown ends that one. The reply to any request that closed a session lists the leases it ended in `endedLeases`, as `{ leaseId, outcome: 'released' }`. This covers `close`, a `batch` with a close step, and a `replay` or `test` script that closes, as well as the session cleanup `test` runs after each script. A successful reply has the field in `data`, and a failed one has it in `error.details`. A gateway in front of the daemon reads the lease end from that reply. When the reply names the stored lease, the CLI forgets it, so the next `open` leases a device again. `disconnect` clears local connection state. -Without `--udid` or `--serial`, `open` does not pick the device on the agent machine: it sends `--platform`, `--target`, and `--device` with the lease request, the daemon behind the proxy picks a matching device, and later commands use the device the lease names. The device therefore does not need to exist before `open`, which lets a service in front of the daemon create or choose it at that point. While that lease is active, an `open` that names a different device is refused instead of running on the leased one; close the session first, or run `connect --force` to start over. +Without `--udid` or `--serial`, `open` does not pick the device on the agent machine: it sends `--platform`, `--target`, and `--device` with the lease request, the daemon behind the proxy picks a matching device, and later commands use the device the lease names. The device therefore does not need to exist before `open`, which lets a service in front of the daemon create or choose it at that point. While that lease is active, an `open` that names a different device is refused instead of running on the leased one; close the session first, or run `connect --force` to start over. An existing lease that names no device, such as one supplied through `leaseId`, is refused with reason `LEASE_SCOPE_MISMATCH` and left active. -`open` names each new lease with an id it makes (32 lowercase hex characters) and sends it with the allocation, so a host in front of the daemon knows the lease id before the daemon allocates. A direct `agent_device.lease.allocate` RPC can pass `leaseId` (16 to 128 hex characters) the same way; an id this daemon has already given a lease, active or ended, is refused with `INVALID_ARGS` and reason `LEASE_ID_IN_USE`, and without one the daemon makes the id. Use the id the reply carries: a run that already holds a lease gets that lease back, and an older daemon ignores the requested id. +`open` names each new lease with an id it makes (32 lowercase hex characters) and sends it with the allocation, so a host in front of the daemon knows the lease id before the daemon allocates. A direct `agent_device.lease.allocate` RPC can pass `leaseId` (16 to 128 hex characters) the same way; repeating the id of a live lease returns that lease when the retry carries the same tenant, run, client, lease backend, and provider, and either names no device or the device the lease binds, so a retry after a lost reply does not allocate a second one. A retry without `ttlMs` keeps the lease's window. A retry that changes any of those fields is refused like any other reused id. The id identifies the lease: one run can hold several leases. Any other id this daemon has already given a lease, active or ended, is refused with `INVALID_ARGS` and reason `LEASE_ID_IN_USE`, and without one the daemon makes the id. Use the id the reply carries: a run that already holds a lease gets that lease back, and an older daemon ignores the requested id. Several agents can share one proxy when each follows the same `connect proxy`, `open`, commands, `close`, and `disconnect` flow. A busy device error means another agent holds the device until it closes its session or its lease expires.