Repository navigation
Lease limit per platform: one iOS and one Android per agent #961
Description
Request: #947
Problem
lease.maxPerRequester is one number for all platforms, default 1. An agent asked to fix a bug on iOS and Android leases the iPhone, then fails on the Pixel with REQUESTER_ALREADY_LEASED (exit 13). The docs' only answer is "ask the operator to raise lease.maxPerRequester", which also lets one agent take two iPhones and starve the queue.
The limit should be configurable per platform, so an operator can say "one iOS and one Android per agent" without allowing two of either.
Who it is for
The operator of a Simlock worker or gateway whose agents test one change on iOS and Android at once, for example a React Native team. It serves every agent kind that leases through the CLI, MCP or HTTP. Under Claude Code a session and its sub-agents share one requester, so this limit is also what lets two sub-agents hold an iPhone and a Pixel at the same time.
agent-device users already get this today: each agent-device session is its own requester and holds one device, so iOS and Android are two sessions. This feature changes nothing on that path; it corrects the docs that say otherwise.
Outcome
- An operator sets
lease.maxPerRequesterto{"ios": 1, "android": 1}. One requester then holds one iOS and one Android lease at once. A second iOS request is refused, and so is a second Android one. - A requester may also wait for an iOS device and an Android device at the same time. Cancelling one (an aborted programmatic-client call, or
DELETE /v1/lease-requests/{id}) cancels only that request.simlock leasecancels nothing on Ctrl-C, as today. - The refusal says which platform's limit was hit and names the lease that holds it.
- A config with a number keeps today's meaning: that many leases across all platforms. Nobody has to change a config.
- The same holds through a gateway, counted across the fleet.
How it works for the user
flowchart TD
A["agent: simlock lease --platform android"] --> B{"held + waiting on this scope<br/>≥ the scope's limit?"}
B -- no --> C{"a request on this platform<br/>already waiting?"}
C -- no --> Q[queue → grant]
C -- yes --> W["exit 13: already has a waiting Android request"]
B -- "yes, held ≥ limit" --> H["exit 13: already holds Android lease <id>"]
B -- "yes, held < limit" --> W2["exit 13: waiting-request message"]
The scope is the request's platform when the limit is an object, and every platform when it is a number.
Operator, before and after:
$ simlock config set lease.maxPerRequester '{"ios":1,"android":1}'
$ simlock daemon stop && simlock daemon startAgent, today with the default limit 1:
$ simlock lease --platform ios --json # granted lease-a
$ simlock lease --platform android --json
Requester claude-code:5f1c already holds lease lease-a; release it (`simlock release lease-a`) before requesting another device
$ echo $?
13Agent, after, with {"ios":1,"android":1}:
$ simlock lease --platform ios --json # granted lease-a
$ simlock lease --platform android --json # granted lease-b
$ simlock lease --platform ios --json
Requester claude-code:5f1c already holds iOS lease lease-a; release it (`simlock release lease-a`) before requesting another iOS device
$ echo $?
13Examples
What a request gets. "Holds" and "waits" are the same requester's leases and requests.
lease.maxPerRequester |
Requester holds / waits | New request | Today | After |
|---|---|---|---|---|
absent (default 1) |
holds iOS | Android | refused, names iOS lease | refused, same message as today |
1 |
waits iOS | Android | refused, waiting | refused, same message as today |
2 |
holds iOS | Android | admitted | admitted |
2 |
waits iOS | Android | refused, waiting | admitted |
2 |
waits iOS | iOS | refused, waiting | refused, same message as today |
{"ios":1,"android":1} |
holds iOS | Android | n/a (config rejected) | admitted |
{"ios":1,"android":1} |
holds iOS | iOS | n/a | refused, names the iOS lease |
{"ios":1,"android":1} |
waits iOS | Android | n/a | admitted |
{"ios":1,"android":1} |
holds iOS phone (--physical) |
iOS simulator | n/a | refused: a phone counts against its platform |
{"ios":2} |
holds 1 Android | Android | n/a | refused: a missing platform's limit is 1 |
{} |
holds iOS | Android | n/a | admitted: both limits are 1 |
Config values:
| Value | Today | After |
|---|---|---|
2 |
accepted | accepted, same meaning |
{"ios":1,"android":1} |
daemon refuses to start | accepted |
{"ios":0}, {"ios":1.5}, {"ios":"1"} |
refused | refused, naming lease.maxPerRequester.ios |
{"ios":1,"tvos":1} |
refused | accepted; tvos warned about and ignored, like any unknown key |
0, -1, "2" |
refused | refused |
What could go wrong
- An agent cancels its Android request and its iOS request is cancelled instead. Likely only if cancel is not narrowed to the platform; a test covers it.
- A gateway counts a requester's fleet leases by the wrong platform and lets two iPhones through. Unlikely; a gateway test covers it.
- An operator writes
simlock config set lease.maxPerRequester.ios 2over a number and silently gets Android 1. Rare; the docs show the JSON form.
Words used
- requester: the identity a lease is counted against, e.g.
claude-code:<session id>, an HTTP token's id, orSIMLOCK_AGENT_ID. - scope: the leases a limit counts. The request's platform when
lease.maxPerRequesteris an object; every platform when it is a number. - waiting request: a lease request admitted but not yet granted or refused.
- physical lease: a lease on an enrolled phone (
simlock lease --physical). It counts against its platform: an iPhone is iOS.
Non-goals
- No per-token cap on agent-device sessions. Each agent-device session stays its own requester holding one device.
- No total cap on top of the object form (for example "two devices, at most one per platform").
- No separate limit for physical devices.
- No change while the daemon runs: a new value applies after a restart, like every other config key.
- No per-requester or per-token overrides.
Completion conditions
- With
{"ios":1,"android":1}on a worker, one requester is granted an iOS and an Android lease at once, and a second iOS request fails with exit 13 and a message naming the iOS lease. - With
{"ios":1,"android":1}, one requester waits for iOS and Android at once; cancelling the Android request leaves the iOS request waiting. - With a number, or with no value, every case in the Examples table gives its "After" result.
- The same holds through a gateway, counted across its workers.
- Every website page that describes the limit describes the new shape, and none says an agent-device token holds one lease at a time.
Open questions
Decisions
- No ADR. ADR 0005 §14 keeps the count fleet-wide on a gateway; the number in it was already made configurable by
lease.maxPerRequesterwithout amending it. - Both config forms, number means total (maintainer, 2026-10-23).
- A waiting request counts against its own platform only, at most one waiting per platform per requester;
lease.canceltakes an optional platform (maintainer, 2026-10-23). - agent-device keeps one requester per agent-device session; the docs are fixed (maintainer, 2026-10-23; one agent-device session holds one device, decided 2026-10-16).
- Taken on recommendation, listed for approval: a platform missing from the object gets 1; a physical lease counts against its platform (a physical request carries its platform,
src/contract/schemas.ts:349-356); the refusal names the platform under the object form and keeps today's text under the number form; a change applies on restart.
Shared values
| Value or state | Rule | Written by | Read by | Source |
|---|---|---|---|---|
lease.maxPerRequester |
A positive integer (total across platforms) or an object with optional ios and android, each a positive integer; a missing platform is 1; default 1. The same two forms in configSchema, so config.get, worker.list and a gateway's view read accept them. |
config file (#966 parses) | worker and gateway admission, config.get, worker.list, gateway view read (#966) |
src/core/config.ts:1093, :927; src/contract/schemas.ts:1090; src/daemon/dispatcher.ts:913; src/gateway/worker-link.ts:423 |
| Admission rule | One pure function, requesterLimitDecision, beside RequesterAlreadyLeasedError in src/leasing/wait-queue.ts, exported through the gateway's leasing allow-list. Refuse when a request on the same platform is already waiting, or when held + waiting in the request's scope ≥ the scope's limit. A gateway probe at a worker checks only the same-platform waiting request. |
#966 | worker and gateway admission (#966) | src/leasing/lease-acquisition-coordinator.ts:314-340, src/gateway/fleet-coordinator.ts:306-325 |
| Refusal messages | Number form: today's texts, picked exactly as today (src/leasing/wait-queue.ts:73-86). Object form, <P> is iOS or Android, <p> is ios or android: limit 1 held Requester <r> already holds <P> lease <id>; release it (\simlock release `) before requesting another device leases and lease.maxPerRequester. is ; release one (`simlock release `) before requesting another device request; wait for it to be granted or cancel it before requesting another device |
#966 (RequesterAlreadyLeasedError) |
worker and gateway admission | src/leasing/wait-queue.ts:62-87 |
lease.cancel input platform |
Optional "ios" or "android". Given: cancels the requester's waiting request on that platform. Absent: the requester's earliest waiting request, as today. This PR bumps the protocol by one. |
#966 (contract) | worker and gateway lease.cancel, simlock-client, HTTP tracker (#966) |
src/contract/operations.ts:336-343 |
Behaviours
| Behaviour (invoker, action, what it sees) | Seam | Cases | Task |
|---|---|---|---|
| Operator's config is read: number or object accepted, bad values refused naming the path | loadConfig (src/core/config.test.ts:716) |
2; {"ios":1,"android":1}; {"ios":2} → android 1; {}; absent → 1; {"ios":0}, {"ios":1.5}, {"ios":"1"} refused naming lease.maxPerRequester.ios; {"tvos":1} warned |
#966 |
| A requester on a worker with an object limit is admitted or refused per platform | createLeasing (src/leasing/create-leasing.test.ts:3891) |
object rows of Examples, physical row included; messages for limit 1, limit 2 and waiting | #966 |
| A requester on a worker with a number limit: held count across platforms, waiting per platform | createLeasing (same describe) |
number rows of Examples | #966 |
| A gateway probe reaching a worker is refused only for a same-platform waiting request | createLeasing with fleetRequestId |
waiting iOS probe + Android probe admitted; waiting iOS + iOS probe refused | #966 |
| A caller cancels a waiting request by platform | lease.cancel through the daemon dispatcher (createLeasing cancel tests, lease-acquisition-coordinator.test.ts:1191) |
platform given, two waiting → only that one cancelled; platform absent → earliest; no match → not-found |
#966 |
An aborted requestLease (programmatic client) and an HTTP DELETE /v1/lease-requests/{id} cancel only their own request |
simlock-client and HTTP tracker tests | iOS and Android waiting, abort Android → iOS still waiting | #966 |
| A requester through a gateway is admitted or refused per platform, counted across workers, after a gateway restart too | FleetLeaseCoordinator (src/gateway/fleet-coordinator.test.ts:4418) |
object: iOS on worker A + Android on worker B admitted, second iOS refused naming the lease; number rows; rebuilt index from worker views counts by platform | #966 |
| A caller cancels a waiting gateway request by platform | gateway lease.cancel |
as the worker row | #966 |
Tasks
One task: the gateway runs on leasing's WaitQueue and on the same config type, so a worker-only PR would change the gateway half-way.
- Per-platform lease limit per requester, on a worker and a gateway #966 Per-platform lease limit per requester, on a worker and a gateway — an operator sets
{"ios":1,"android":1}and one agent holds an iPhone and a Pixel, never two of either, directly or through a gateway. Risk: a requester gets a second device of one platform, or cancelling one waiting request cancels the other.
Every Completion condition → #966.
Open work in the same area:
- One command installs the agent rules where agent tools read them #157 / Add
simlock instructions: the rules an agent must follow, printable and served over MCP #147 (PR feat(cli): simlock instructions prints the rules an agent must follow #960), agent instructions: says the operator decides the limit; line 55 "you already hold a lease" stays true per platform. No Depends on; a note goes to One command installs the agent rules where agent tools read them #157. - fix(daemon): status.get shows an agent only its own leases and queue entries #893, fix(daemon): exec children get a minimal environment; docs say what an agent token can do #891 (open PRs) edit
lease-lifecycle.mdx,remote-agents.mdx,access-control.mdx: same pages, different lines; the second to merge rebases. No Depends on. - A standalone worker writes a private agent-device profile for local agents #956 (PR feat(modules): a standalone worker writes a private agent-device profile for local agents #963), A standalone worker serves agent-device on localhost by default #957 (local agent-device) rewrite
using-devices/agent-device.mdx; Per-platform lease limit per requester, on a worker and a gateway #966 adds one sentence near:22. The second to merge rebases. No Depends on. - A request waits only behind requests for the same platform #387 (one waiting line per platform): a queue-order change, not admission. No Depends on.
- device.exec fills in the lease's device and refuses another #958 / MCP: run_on_device runs a command on the session's lease #959 (MCP run_on_device): uses the session's lease; the default limit is unchanged. No Depends on.
Written by an agent.
Activity
V3RON commentedon Oct 23, 2026
Spec updated: Problem through Behaviours, Decisions, Shared values and Tasks written; one task, #966, checked ready and approved.
Written by an agent.
feat(leasing): lease.maxPerRequester per platform (#975)
github-actions commentedon Oct 24, 2026
All 1 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.V3RON commentedon Oct 24, 2026
Delivery run
Merged: #975 (#966)
Parked: none
Waiting: none
Decided: #966 — test
lease.cancel {platform}at the daemon and gateway dispatcher seams? → yes, tests added, no behaviour changeWritten by an agent.
V3RON commentedon Oct 24, 2026
All completion conditions hold on
main(91b0af8, PR #975):src/leasing/create-leasing.test.ts:4012(iOS then Android granted),:4030-4084(second iOS refused withalready holds iOS lease <id>); physical iPhone counts against iOS:src/leasing/physical-leasing.test.ts:536. Rule:src/leasing/wait-queue.ts:118-145.src/leasing/create-leasing.test.ts:4086-4148;src/daemon/dispatcher.test.ts:1310(lease.cancel {platform}, iOS still waiting at:1375);src/simlock-client/client.test.ts:1048(aborted call);src/http/tracker.test.ts:169(DELETE /v1/lease-requests/{id}).src/leasing/create-leasing.test.ts:3918,:3939,:3959,:3971,:3989,:4150-4233(1/2 with waiting iOS then Android/iOS); held counted across platforms in number form atsrc/leasing/wait-queue.ts:121-125. Config values:src/core/config.test.ts:716(absent→1, 2, objects,{}),:740(0, -1, 1.5, "2",{"ios":0}… refused naming the path),:761(tvoswarned and ignored).src/gateway/fleet-coordinator.ts:311); testssrc/gateway/fleet-coordinator.test.ts:4519(iOS on one worker + Android on another, second iOS refused, also after index rebuild),:4553(number 1),:4592(wait both, cancel by platform);src/gateway/dispatcher.test.ts:2238.website/src/content/docs/docs/reference/configuration.mdx:62,worker/configuration.mdx:126-135,gateway/routing.mdx:61,gateway/setup.mdx:71,129,gateway/access-control.mdx:26,using-devices/remote-agents.mdx:34,using-devices/lease-lifecycle.mdx:118,reference/errors.mdx:43;using-devices/agent-device.mdx:26says each agent-device session is its own requester holding one device.No ADR to flip (the spec decided "No ADR").
Written by an agent.