Skip to content

Per-platform lease limit per requester, on a worker and a gateway #966

Closed

Description

@V3RON

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]
Loading

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 WaitQueue pending waiter).

The rule, one decision for worker and gateway (requesterLimitDecision, below):

  1. A waiting request of the requester on the same platform → refuse.
  2. Else held in scope + waiting in scope ≥ the scope's limit → refuse.
  3. 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 $?
13

The 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
Loading
  • src/core/config.ts: type, validator (number or objectValidator({ ios, android }) of positive integers; a missing platform is 1).
  • src/contract/schemas.ts: configSchema.lease.maxPerRequester becomes number or { ios?: number, android?: number }.
  • src/contract/operations.ts: lease.cancel input gains platform?: "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?); requesterLimitDecision and the new texts beside RequesterAlreadyLeasedError.
  • src/leasing/index.ts, .oxlintrc.json: export requesterLimitDecision and add it to the gateway's allow-list.
  • src/leasing/lease-acquisition-coordinator.ts: admission calls requesterLimitDecision; cancelPending(requesterId, platform?).
  • src/leasing/create-leasing.ts, lease-ports.ts, testing.ts: the option type, and cancelPending and pendingRequestOwner taking platform?.
  • src/contract/roles.ts, src/daemon/dispatch.ts: the authorize context passes lease.cancel's platform to pendingRequestOwner (F22), so the owner checked is the owner of the request that is cancelled.
  • src/daemon/dispatcher.ts, src/gateway/dispatcher.ts: pass input.platform to cancelPending and pendingRequestOwner.
  • src/gateway/fleet-coordinator.ts: admission calls requesterLimitDecision; option type; cancelPending and pendingRequestOwner take platform?.
  • 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's deviceId (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: #cancelOnAbort sends the aborted request's platform.
  • src/http/tracker.ts: cancel sends 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 ("holds lease.maxPerRequester leases" → 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 shows simlock daemon restart (F19).

agent-device code is not touched (F13).

Contract and event changes

  • config.get output: lease.maxPerRequester is a positive integer, or an object with optional ios and android, each a positive integer. The daemon only ever sends what its own validator accepted.
  • lease.cancel input: optional platform, "ios" or "android"; anything else is BAD_REQUEST (the existing enum parse).
  • This PR bumps the protocol by one.
  • No event changes: lease.rejected already-leased keeps its fields; EVENTS.md:55 wording 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: requesterLimitDecision is 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.maxPerRequester is read as 2; {"ios":1,"android":1}; {"ios":2} (android 1); {} (both 1); absent (1); {"ios":0}, {"ios":1.5}, {"ios":"1"} refused naming lease.maxPerRequester.ios; {"ios":1,"tvos":1} accepted with a warning; and config.get returns 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.cancel on a worker: iOS and Android waiting, cancel {platform:"android"} → Android cancelled, iOS still waiting; no platform → the earliest cancelled; {platform:"ios"} with only Android waiting → not-found.
  • Client: two requestLease calls (iOS, Android) waiting; aborting the Android one sends lease.cancel with platform: "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; gateway lease.cancel {platform} cancels only that one; number form 1, 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 (pendingRequestOwner with the platform, on a worker and on the gateway FleetLeaseCoordinator); a test goes red when either dispatcher drops input.platform from cancelPending or from pendingRequestOwner, or when the gateway pendingRequestOwner ignores it. (decided in delivery, pending the maintainer)

Done when

  • pnpm test:changed passes with the Tests lines above.
  • CI's pnpm check is green, lint included: the gateway imports requesterLimitDecision through the allow-list.
  • The website build passes (toolchain.md line 15).
  • remote-agents.mdx:42, gateway/access-control.mdx:26 and gateway/setup.mdx:129 say the limit is per requester and per platform when the operator sets the object form, and agent-device.mdx says 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/docs lists 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

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  3. self-assigned this
    on Oct 24, 2026
  4. 5 remaining items

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

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions