Repository navigation
Per-platform lease limit per requester, on a worker and a gateway #966
Description
Part of #961.
Scope
All of #961: lease.maxPerRequester takes a number (as today) or a per-platform object, on a worker and on a gateway. A requester may wait for one request per platform at once, and lease.cancel can name the platform to cancel. The website pages that describe the limit, and the agent-device pages, say so.
One task, not a worker task and a gateway task: the gateway runs on leasing's WaitQueue (src/gateway/queue.ts:79-80) and on the same config type (src/daemon/main.ts:951). A worker-only PR would change both under the gateway and leave it half done. About 15 code modules plus docs pages; the tests are 8 lines.
In short
An operator writes "maxPerRequester": {"ios": 1, "android": 1} under lease in config.json (or simlock config set lease.maxPerRequester '{"ios":1,"android":1}'), then stops and starts the daemon. One requester can then hold one iOS and one Android lease, and wait for one of each, but never two of one platform. A number keeps today's meaning: that many leases across all platforms.
flowchart LR
R[lease.request] --> D["requesterLimitDecision (changed)"]
D -- admit --> Q[queue → grant]
D -- refuse --> E["REQUESTER_ALREADY_LEASED, exit 13 (message changed under object form)"]
C["lease.cancel {platform?} (changed)"] --> W[waiting request of that platform]
Terms:
- scope: the leases a limit counts. Object form: those of the request's platform. Number form: all of the requester's leases.
- waiting request: admitted, not yet granted or refused (a
WaitQueuepending waiter).
The rule, one decision for worker and gateway (requesterLimitDecision, below):
- A waiting request of the requester on the same platform → refuse.
- Else held in scope + waiting in scope ≥ the scope's limit → refuse.
- Else admit.
Which text a refusal gets: number form, exactly as today's requesterRefusalMessage picks it from the held count, the limit and whether a request is waiting (src/leasing/wait-queue.ts:73-86); object form, the held text when held in scope ≥ the scope's limit, else the waiting text.
A gateway probe at a worker (fleetRequestId set) applies step 1 only, as today it skips the held count (src/leasing/lease-acquisition-coordinator.ts:323-326).
Messages. Number form: today's three texts, unchanged (src/leasing/wait-queue.ts:78-86). Object form, where <P> is iOS or Android, <p> is ios or android, <r> the requester, <id> the earliest-granted lease in scope, <held> the leases held in scope and <limit> the scope's limit:
- limit 1, held:
Requester <r> already holds <P> lease <id>; release it (simlock release) before requesting another <P> device - limit > 1, held:
Requester <r> already holds <held> <P> leases and lease.maxPerRequester.<p> is <limit>; release one (simlock release) before requesting another <P> device - waiting:
Requester <r> already has a waiting <P> request; wait for it to be granted or cancel it before requesting another <P> device
Code REQUESTER_ALREADY_LEASED, exit 13, lease.rejected reason already-leased: as today.
Session, 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 $?
13The Examples tables in #961 are this task's inputs table.
If this goes wrong, a user sees one agent holding two iPhones, or cancelling an Android request (an aborted programmatic-client call, or DELETE /v1/lease-requests/{id}) cancelling the iOS one instead. simlock lease itself cancels nothing on Ctrl-C, today or after (F21).
Technical spec
Facts
Commit 3de46d1.
| # | Fact | Source |
|---|---|---|
| F1 | lease.maxPerRequester is a positive integer, default 1, in config type, defaults and validator. |
src/core/config.ts:61, :328, :927, :1093 |
| F2 | The contract's configSchema declares maxPerRequester: z.number(); it is config.get's output, parsed again by worker.list and by every gateway's view read. |
src/contract/schemas.ts:1090; src/contract/operations.ts:643; src/daemon/dispatcher.ts:913; src/gateway/worker-link.ts:423 |
| F3 | A per-platform object validator exists to copy: lease.identity is objectValidator({ ios, android }); an unknown key is warned and ignored. |
src/core/config.ts:1088-1091; comment :1080-1083; src/core/validation.ts:21 |
| F4 | Worker admission counts the requester's leases in the registry snapshot against maxPerRequester ?? 1, and refuses any waiting request of the requester; a probe skips the held count. |
src/leasing/lease-acquisition-coordinator.ts:314-340 |
| F5 | Gateway admission counts leaseIndex.leaseCount(requesterId) and refuses any waiting request. |
src/gateway/fleet-coordinator.ts:306-325 |
| F6 | WaitQueue keeps one #pendingRequesters set keyed by requester only; a waiter keeps its request, a DeviceRequest with platform. |
src/leasing/wait-queue.ts:152, :188-189, :233, :316, :326; Waiter interface in src/leasing/wait-queue.ts; src/contract/schemas.ts:349-350 |
| F7 | The gateway's FleetQueue wraps leasing's WaitQueue. |
src/gateway/queue.ts:79-80 |
| F8 | The gateway may import from leasing only an allow-list; RequesterAlreadyLeasedError and WaitQueue are on it. |
.oxlintrc.json:41-58 |
| F9 | Refusal texts and RequesterAlreadyLeasedError. |
src/leasing/wait-queue.ts:62-87 |
| F10 | lease.cancel input is { requesterId? }; worker and gateway handlers cancel findPendingWaiter(requesterId), the first pending waiter of the requester. |
src/contract/operations.ts:336-343; src/daemon/dispatcher.ts:580-584; src/gateway/dispatcher.ts:726-728; src/leasing/lease-acquisition-coordinator.ts:680-692; src/leasing/wait-queue.ts:195-200 |
| F11 | The client cancels on abort with { requesterId } only; the request it aborts carries its platform. |
src/simlock-client/client.ts:791-803, :822-828 |
| F12 | HTTP DELETE /v1/lease-requests/{id} resolves the record and cancels by { requesterId: before.requesterId }; the record keeps its platform. |
src/http/lease-declarations.ts:274-275; src/http/tracker.ts:329-339, :384 |
| F13 | agent-device sets requesterId to its lease id, and cancels by that id. |
src/agent-device/front/allocate.ts:184, :208; src/agent-device/worker.ts:164; src/agent-device/gateway.ts:137 |
| F14 | A grant carries the device with spec.platform. |
src/contract/schemas.ts:339, :260, :118 |
| F15 | The gateway's lease index entry has no platform; it is built at grant and rebuilt from view.leases, whose records carry deviceId; the same view carries devices with id and spec. |
src/gateway/lease-index.ts:25-32, :220, :261-268; src/gateway/fleet-coordinator.ts:1261-1269, :1460; src/contract/schemas.ts:298, :1370-1371, :230-232 |
| F16 | A physical request carries platform like any other. |
src/contract/schemas.ts:349-356 |
| F17 | simlock config set <key> <value> JSON-parses the value, so '{"ios":1,"android":1}' is an object. |
src/cli/commands/config.ts:103-109 |
| F18 | The protocol is one number range in PROTOCOL_VERSION_RANGE. |
src/contract/protocol.ts:13 |
| F19 | The CLI has simlock daemon stop and simlock daemon start, no restart. |
website/src/content/docs/docs/worker/configuration.mdx:44-45; src/cli/tree.ts (no restart) |
| F21 | simlock lease calls requestLease with no abort signal; Ctrl-C has no effect until the grant. |
src/cli/commands/lease.ts:176-195; website/src/content/docs/docs/using-devices/lease-lifecycle.mdx:107 |
| F22 | lease.cancel's authorize hook reads pendingRequestOwner(requesterId), the owner of the requester's first waiter. |
src/contract/roles.ts:42; src/daemon/dispatch.ts:369; src/daemon/dispatcher.ts:369; src/gateway/dispatcher.ts:316; src/leasing/lease-ports.ts:24-25; src/leasing/lease-acquisition-coordinator.ts:246; src/gateway/fleet-coordinator.ts:266 |
| F20 | Existing limit tests: worker createLeasing describe; gateway FleetLeaseCoordinator describe; config test. |
src/leasing/create-leasing.test.ts:3891; src/gateway/fleet-coordinator.test.ts:4418; src/core/config.test.ts:716 |
Modules touched
Searches: maxPerRequester, hasPendingRequester, findPendingWaiter, cancelPending, pendingRequestOwner (11 non-test hits), lease.cancel, RequesterAlreadyLeasedError, configSchema, and the docs terms maxPerRequester, one lease, per requester, REQUESTER_ALREADY_LEASED under website/ and docs/internal/.
flowchart LR
CFG["core/config.ts *"] --> LEAS
SCH["contract/schemas.ts, operations.ts, protocol.ts *"]
LEAS["leasing: wait-queue.ts, lease-acquisition-coordinator.ts, index.ts *"]
DISP["daemon/dispatcher.ts *"] --> LEAS
GW["gateway: fleet-coordinator.ts, lease-index.ts, queue.ts, dispatcher.ts *"] --> LEAS
CLI["simlock-client/client.ts *"] --> SCH
HTTP["http/tracker.ts *"] --> DISP
src/core/config.ts: type, validator (number orobjectValidator({ ios, android })of positive integers; a missing platform is 1).src/contract/schemas.ts:configSchema.lease.maxPerRequesterbecomes number or{ ios?: number, android?: number }.src/contract/operations.ts:lease.cancelinput gainsplatform?: "ios" | "android".src/contract/protocol.ts: this PR bumps the protocol by one.src/leasing/wait-queue.ts: pending tracking by requester and platform;findPendingWaiter(requesterId, platform?);requesterLimitDecisionand the new texts besideRequesterAlreadyLeasedError.src/leasing/index.ts,.oxlintrc.json: exportrequesterLimitDecisionand add it to the gateway's allow-list.src/leasing/lease-acquisition-coordinator.ts: admission callsrequesterLimitDecision;cancelPending(requesterId, platform?).src/leasing/create-leasing.ts,lease-ports.ts,testing.ts: the option type, andcancelPendingandpendingRequestOwnertakingplatform?.src/contract/roles.ts,src/daemon/dispatch.ts: the authorize context passeslease.cancel'splatformtopendingRequestOwner(F22), so the owner checked is the owner of the request that is cancelled.src/daemon/dispatcher.ts,src/gateway/dispatcher.ts: passinput.platformtocancelPendingandpendingRequestOwner.src/gateway/fleet-coordinator.ts: admission callsrequesterLimitDecision; option type;cancelPendingandpendingRequestOwnertakeplatform?.src/gateway/lease-index.ts: an entry carries its platform: from the grant's device (F14), and on rebuild from the view's device with the lease'sdeviceId(F15). A rebuilt lease whose device the view does not list counts against every platform.src/gateway/queue.ts: pass-throughs.src/simlock-client/client.ts:#cancelOnAbortsends the aborted request's platform.src/http/tracker.ts:cancelsends the record's platform.src/mcp/session.ts:157-158: the comment names the per-platform limit and drops its ADR citation.- Manuals:
src/leasing/AGENTS.md:39,src/gateway/AGENTS.md:65;docs/internal/EVENTS.md:55("holdslease.maxPerRequesterleases" → in scope). - Website:
reference/configuration.mdx:62,worker/configuration.mdx:98,:124-127,getting-started/connect-your-agent.mdx:22,using-devices/lease-lifecycle.mdx:110-118,reference/errors.mdx:43,worker/troubleshooting.mdx:120,introduction/how-it-works.mdx:40,reference/cli.mdx:607,using-devices/remote-agents.mdx:42,:197,using-devices/programmatic-client.mdx:41,:144,using-devices/errors.mdx:33,using-devices/agent-device.mdx(a paragraph of its own after the list that holds:22, not inside that bullet: each agent-device session is its own requester and holds one device; a second device is a second session),gateway/routing.mdx:61,gateway/access-control.mdx:26,gateway/setup.mdx:71,:129. No page showssimlock daemon restart(F19).
agent-device code is not touched (F13).
Contract and event changes
config.getoutput:lease.maxPerRequesteris a positive integer, or an object with optionaliosandandroid, each a positive integer. The daemon only ever sends what its own validator accepted.lease.cancelinput: optionalplatform,"ios"or"android"; anything else isBAD_REQUEST(the existing enum parse).- This PR bumps the protocol by one.
- No event changes:
lease.rejectedalready-leasedkeeps its fields;EVENTS.md:55wording only.
Other code on the same state
Searches: #pendingRequesters (5 hits, all in wait-queue.ts), hasPendingRequester (4), findPendingWaiter (4), leaseCount( (2), maxPerRequester (12 in src/, non-test).
| Component | Same state | Both acting |
|---|---|---|
WaitQueue.reject, settle paths (wait-queue.ts:316, :326) |
pending set | they remove the (requester, platform) entry; a new request of that platform is admitted after, as today for the requester. |
pendingRequestOwner (F22), lease.cancel authorize |
first waiter of a requester | reads the waiter cancelPending will cancel: both take the same platform, so the owner check and the cancel name one request. |
| agent-device cancel (F13) | one waiter per requester | unchanged: one waiter per lease id. |
| Gateway index rebuild vs grant add (F15) | index entry | both set the platform; the first add wins, as today. |
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
config.json read |
bad lease.maxPerRequester |
the daemon refuses to start, naming the path (e.g. lease.maxPerRequester.ios), as for any bad value |
at start |
lease.cancel {platform} |
no waiting request on that platform | not-found |
— |
lease.cancel {platform} |
that request is provisioning | not-cancellable, as today |
— |
lease.cancel from a client one protocol behind |
— | PROTOCOL_VERSION_UNSUPPORTED, as for every bump |
— |
| gateway view read of a worker one protocol behind | — | the worker is incompatible, as for every bump |
— |
State: admission and cancel run under the existing serialized decision; two requests at once are decided one after the other, as today. A restart fails every waiting request (daemon-restarted); nothing new is stored.
Rules in play
- Architecture rule 10, one decision in one place:
requesterLimitDecisionis the only copy; worker and gateway call it. - ADR 0005 §14 "One lease per requester is fleet-wide": the gateway still counts across the fleet; the number was already configurable.
- A physical request carries its platform (F16): a phone counts against its platform, decided in Lease limit per platform: one iOS and one Android per agent #961.
- ADR 0023 §4 "One simlock token is one tenant", and the maintainer's 2026-10-16 decision that one agent-device session holds one device: agent-device code unchanged (F13), docs fixed.
- Documentation rule 9: every page under Modules touched changes in this PR.
- Contract changes bump the protocol by one.
Tests
Seams: createLeasing (model: src/leasing/create-leasing.test.ts:3891), FleetLeaseCoordinator (model: src/gateway/fleet-coordinator.test.ts:4418), loadConfig (src/core/config.test.ts:716), client.test.ts, tracker.test.ts. Today's number-form texts are proven by the existing tests at create-leasing.test.ts:3912-3965; they must stay green unchanged.
- Config: an operator's
lease.maxPerRequesteris read as2;{"ios":1,"android":1};{"ios":2}(android 1);{}(both 1); absent (1);{"ios":0},{"ios":1.5},{"ios":"1"}refused naminglease.maxPerRequester.ios;{"ios":1,"tvos":1}accepted with a warning; andconfig.getreturns the object form and passes its output schema. - Worker, object form
{"ios":1,"android":1}: holds iOS + Android request → granted; holds iOS + iOS request → refused with the limit-1 iOS text naming the lease; holds an iOS phone + iOS simulator request → refused; waits iOS + Android request → admitted; waits iOS + iOS request → waiting iOS text;{"ios":2}holds one Android + Android request → refused;{"ios":2,"android":1}holds two iOS + iOS → limit > 1 text with held 2 and limit 2; holds one iOS and waits one iOS under{"ios":2}+ iOS → waiting iOS text. - Worker, number form:
2, waits iOS + Android request → admitted;2, waits iOS + iOS → today's waiting text;1, waits iOS + Android → today's waiting text;2, holds iOS and waits Android + iOS request → today's waiting text. - Worker, gateway probe (
fleetRequestId) under{"ios":1,"android":1}: a waiting iOS probe + Android probe → admitted; + iOS probe → refused. lease.cancelon a worker: iOS and Android waiting, cancel{platform:"android"}→ Android cancelled, iOS still waiting; noplatform→ the earliest cancelled;{platform:"ios"}with only Android waiting →not-found.- Client: two
requestLeasecalls (iOS, Android) waiting; aborting the Android one sendslease.cancelwithplatform: "android"and leaves iOS waiting. - HTTP:
DELETE /v1/lease-requests/{id}of the Android request cancels it and leaves the iOS request waiting. - Gateway,
{"ios":1,"android":1}: iOS lease on worker A + Android request → granted on worker B; second iOS → refused with the iOS text naming the lease; after the index is rebuilt from worker views alone, the second iOS is still refused and Android still admitted; waits iOS + Android → admitted; gatewaylease.cancel {platform}cancels only that one; number form1, holds iOS + Android → today's text. - Dispatcher, daemon and gateway: with iOS and Android waiting for one requester,
lease.cancel {platform:"android"}cancels the Android request and leaves iOS waiting, and its authorize hook checks the owner of the Android request (pendingRequestOwnerwith the platform, on a worker and on the gatewayFleetLeaseCoordinator); a test goes red when either dispatcher dropsinput.platformfromcancelPendingor frompendingRequestOwner, or when the gatewaypendingRequestOwnerignores it. (decided in delivery, pending the maintainer)
Done when
pnpm test:changedpasses with the Tests lines above.- CI's
pnpm checkis green, lint included: the gateway importsrequesterLimitDecisionthrough the allow-list. - The website build passes (
toolchain.mdline 15). remote-agents.mdx:42,gateway/access-control.mdx:26andgateway/setup.mdx:129say the limit is per requester and per platform when the operator sets the object form, andagent-device.mdxsays each agent-device session is its own requester holding one device.grep -rni "maxPerRequester\|one lease at a time\|REQUESTER_ALREADY_LEASED\|request waiting" website/src/content/docslists only lines that are true under both forms.- No slow-lane test: the platform is faked in every Tests line.
Out of scope
- A per-token cap on agent-device sessions.
- A total cap on top of the object form.
- A separate limit for physical devices.
- Applying a changed value without a restart.
Depends on
Written by an agent.
Activity
test: a requester at its per-platform limit is refused with that plat…
feat(leasing): a per-platform refusal names the platform and its limit (
test: a waiting request blocks another on its platform only under a p…
feat(leasing): a waiting request blocks another on its platform only …
test: under a numeric limit a waiting request on another platform cou…
feat(leasing): a numeric limit counts waiting requests of any platfor…
test: a gateway probe is refused only by a waiting probe on its own p…
test: cancelPending cancels the waiting request of the platform it na…
feat(leasing): cancelPending cancels the waiting request of the platf…
feat(contract): lease.cancel names the platform of the request to can…
5 remaining items
test: on a gateway a waiting request blocks its platform only, lease.…
test: aborting one of two waiting requests cancels the one of its pla…
feat(client): aborting a request cancels the waiting request of its p…
test: cancelling a lease request over HTTP cancels the request of its…
feat(http): cancelling a lease request names the platform of the reco…
test: a physical iPhone lease counts against the iOS limit of a per-p…
docs: lease.maxPerRequester per platform in the manuals, the events t…
refactor(gateway): drop the unused hasPendingRequester pass-through a…
refactor(gateway): drop a stale Fallow entry on ownerId (#966)
test: audit fixes for per-platform requester limit (#966)
test: lease.cancel {platform} at the daemon and gateway dispatchers (#…
test: give the gateway dispatcher harness the per-platform requester …
docs: fallow comments on fleet coordinator stand alone (#966)
feat(drivers): iOS simulators can live in Xcode's device set (drivers…