Skip to content

Lease limit per platform: one iOS and one Android per agent #961

Closed
1 of 1 issue completed
Closed

Description

@V3RON

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.maxPerRequester to {"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 lease cancels 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 &lt;id&gt;"]
  B -- "yes, held < limit" --> W2["exit 13: waiting-request message"]
Loading

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 start

Agent, 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 $?
13

Agent, 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 $?
13

Examples

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 2 over 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, or SIMLOCK_AGENT_ID.
  • scope: the leases a limit counts. The request's platform when lease.maxPerRequester is 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.maxPerRequester without 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.cancel takes 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; limit > 1 held Requester already holds

leases and lease.maxPerRequester.

is ; release one (`simlock release `) before requesting another

device; waiting Requester already has a waiting

request; wait for it to be granted or cancel it before requesting another

device. is the earliest-granted lease in scope. Object form: the held text when held in scope ≥ limit, else the waiting text. CodeREQUESTER_ALREADY_LEASED, exit 13, lease.rejectedreasonalready-leased`, as today.

#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:

Written by an agent.

Activity

  1. added
    feature:specSpec in progress, business and technical together.
    on Oct 23, 2026
  2. added
    feature:plannedSplit into tasks. Never picked up itself.
    and removed
    feature:specSpec in progress, business and technical together.
    on Oct 23, 2026
  3. V3RON commented on Oct 23, 2026

    @V3RON
    ContributorAuthor

    Spec updated: Problem through Behaviours, Decisions, Shared values and Tasks written; one task, #966, checked ready and approved.

    Written by an agent.

  4. github-actions commented on Oct 24, 2026

    @github-actions

    All 1 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  5. V3RON commented on Oct 24, 2026

    @V3RON
    ContributorAuthor

    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 change

    Written by an agent.

  6. V3RON commented on Oct 24, 2026

    @V3RON
    ContributorAuthor

    All completion conditions hold on main (91b0af8, PR #975):

    • Object form on a worker: iOS + Android at once, second iOS exit 13 naming the iOS lease. src/leasing/create-leasing.test.ts:4012 (iOS then Android granted), :4030-4084 (second iOS refused with already holds iOS lease <id>); physical iPhone counts against iOS: src/leasing/physical-leasing.test.ts:536. Rule: src/leasing/wait-queue.ts:118-145.
    • Waits for iOS and Android at once; cancelling Android leaves iOS waiting. 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}).
    • Number or no value: every Examples row gives its After result. 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 at src/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 (tvos warned and ignored).
    • Same through a gateway, counted across workers. Gateway admission uses the same rule (src/gateway/fleet-coordinator.ts:311); tests src/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 pages describe the new shape; none says an agent-device token holds one lease at a time. 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:26 says each agent-device session is its own requester holding one device.

    No ADR to flip (the spec decided "No ADR").

    Written by an agent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature:plannedSplit into tasks. Never picked up itself.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions