Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
68dbfbf
test: a requester holding an iOS lease is granted an Android one unde…
V3RON Oct 24, 2026
fa98fee
feat(leasing): requesterLimitDecision counts a requester's leases per…
V3RON Oct 24, 2026
1281048
feat(contract): protocol 61 for the per-platform lease.maxPerRequeste…
V3RON Oct 24, 2026
1f90cec
test: a requester at its per-platform limit is refused with that plat…
V3RON Oct 24, 2026
46c9836
feat(leasing): a per-platform refusal names the platform and its limi…
V3RON Oct 24, 2026
cdfc035
test: a waiting request blocks another on its platform only under a p…
V3RON Oct 24, 2026
cac3012
feat(leasing): a waiting request blocks another on its platform only …
V3RON Oct 24, 2026
729cce0
test: under a numeric limit a waiting request on another platform cou…
V3RON Oct 24, 2026
80512b4
feat(leasing): a numeric limit counts waiting requests of any platfor…
V3RON Oct 24, 2026
7de80a2
test: a gateway probe is refused only by a waiting probe on its own p…
V3RON Oct 24, 2026
e7994ae
test: cancelPending cancels the waiting request of the platform it na…
V3RON Oct 24, 2026
cb66a89
feat(leasing): cancelPending cancels the waiting request of the platf…
V3RON Oct 24, 2026
47cc8bd
feat(contract): lease.cancel names the platform of the request to can…
V3RON Oct 24, 2026
c8461ed
test: an operator's per-platform lease.maxPerRequester is read, valid…
V3RON Oct 24, 2026
dfe4ed9
feat(core): lease.maxPerRequester takes a number or a per-platform ob…
V3RON Oct 24, 2026
d3f43c1
test: config.get round-trips a per-platform lease.maxPerRequester thr…
V3RON Oct 24, 2026
269b39c
test: on a gateway a requester holding an iOS lease is granted an And…
V3RON Oct 24, 2026
c1f8431
feat(gateway): the fleet index counts a requester's leases per platfo…
V3RON Oct 24, 2026
0b243ed
test: on a gateway a waiting request blocks its platform only, lease.…
V3RON Oct 24, 2026
dccd32e
test: aborting one of two waiting requests cancels the one of its pla…
V3RON Oct 24, 2026
1bcb718
feat(client): aborting a request cancels the waiting request of its p…
V3RON Oct 24, 2026
8f49ef8
test: cancelling a lease request over HTTP cancels the request of its…
V3RON Oct 24, 2026
e947c2d
feat(http): cancelling a lease request names the platform of the reco…
V3RON Oct 24, 2026
1ff9bff
test: a physical iPhone lease counts against the iOS limit of a per-p…
V3RON Oct 24, 2026
d91e6e9
docs: lease.maxPerRequester per platform in the manuals, the events t…
V3RON Oct 24, 2026
b324d9f
refactor(gateway): drop the unused hasPendingRequester pass-through a…
V3RON Oct 24, 2026
436bac6
refactor(gateway): drop a stale Fallow entry on ownerId (#966)
V3RON Oct 24, 2026
189628a
test: audit fixes for per-platform requester limit (#966)
V3RON Oct 24, 2026
f4dcdc8
test: lease.cancel {platform} at the daemon and gateway dispatchers (…
V3RON Oct 24, 2026
7e68426
test: give the gateway dispatcher harness the per-platform requester …
V3RON Oct 24, 2026
b98a3de
docs: fallow comments on fleet coordinator stand alone (#966)
V3RON Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"LeaseRecord",
"LeaseRequestFailure",
"LeaseRequestLimits",
"MaxPerRequester",
"SerializedDecision",
"UsageReader",
"createUsageReader",
Expand All @@ -40,6 +41,7 @@
{
"group": ["**/leasing/index.js"],
"allowImportNames": [
"HeldLease",
"IdempotencyConflictError",
"InMemoryLeaseRequestStore",
"LeaseRequestBook",
Expand All @@ -51,6 +53,7 @@
"ReplayedLeaseRequestError",
"RequestCancelledError",
"RequesterAlreadyLeasedError",
"requesterLimitDecision",
"WaitQueue",
"WaiterState",
"WaitingRequest"
Expand Down
2 changes: 1 addition & 1 deletion docs/internal/EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ in short: `subject.past-tense-fact`, emitted post-commit, facts not commands.
| `lease.renewed` | lease id, new deadline | a `lease.renew` succeeded — whether it came from `simlock lease renew`, `POST /v1/leases/{id}/renew`, or the renew timer a running `simlock lease` / MCP session keeps over its own lease. There is one renew path and this is it | LeaseLifecycle | implemented (payload per ADR 0004 pending) |
| `lease.released` | lease id, device id, reason (explicit/killed/device-lost), owner id | an explicit `lease.release` (which is what a `simlock lease` holder does on its way out), (killed) an operator `release --all` or `nuke`, or (device-lost) a leased device could not be recovered after it stopped running outside simlock, or a daemon start found its device not running, or (device-lost, ADR 0022 §7) a physical device was not present for the grace period (`health.stableObservations` reads in a row, `health.probeIntervalMs` apart; emitted by the `PhysicalDeviceWatcher` through `LeaseReleaseCoordinator.endForAbsentDevice`; the device becomes `absent` in the same write and nothing is wiped), and the device of that lease was wiped and returned to the pool, left waiting in `reclaiming` on a platform the daemon could not list, or marked missing. Closing a connection is not a release and never emits this | LeaseLifecycle | implemented (payload per ADR 0004 pending) |
| `lease.expired` | lease id, device id, owner id | the lease's deadline passed with no `lease.renew` behind it — the grant-time TTL, or the TTL of the last renew, simply ran out. This is the one way a lease ends without somebody asking, and the only bound on a holder that was killed outright | LeaseLifecycle | implemented (payload per ADR 0004 pending) |
| `lease.rejected` | request id, requester (both required on every reason, additive, ADR 0016 §2), request spec (as on `lease.requested`; `full` replaced by `mode`, ADR 0007 §13; `model` optional and `class` added, ADR 0015 §9; `osVersion` may be a range as typed, ADR 0015 §2), reason (timeout/no-wait/unresolvable-spec/no-worker/already-leased/lease-id-taken/boot-timeout/killed/updating/cancelled/daemon-restarted/worker-failed), `code` and `worker` on `worker-failed` (ADR 0021 §4, additive; `worker`, never `workerId`, which marks a relayed event, ADR 0014 §6) | a request ended without a grant. A worker emits it only for its local requests; a probe ends in `lease.declined` there (ADR 0021 §2). A gateway emits it for every ending that is not a grant (ADR 0021 §4): its own reasons, `unresolvable-spec` for the last cannot-serve refusal whether or not the request had queued, and `worker-failed` for any other failure on the worker it went to -- a terminal refusal, a failure after progress, `WORKER_UNREACHABLE`, `INTERNAL`, a dispatch timeout, a worker's own `REQUESTER_ALREADY_LEASED` or `LEASE_ID_TAKEN` -- with `code` the error code the caller got and `worker` the worker. A worker's `PROVISION_FAILED` before it pushed progress is not among them (after progress it is a failure after progress, relayed as it is): the gateway walks on to the next worker, and only when every worker that could take the request has failed so does it end `worker-failed` with code `PROVISION_FAILED` and the last worker (#637). `dispose()` emits nothing, so a request still open when a gateway stops has no ending event (ADR 0021 §5 plans for usage to close those at the next `daemon.started`; not built yet). `already-leased` means the requester already has a waiting request, or already holds `lease.maxPerRequester` leases (ADR 0023); a worker skips the held-lease count for a probe, so it declines a probe `already-leased` only for a waiting one. A request refused at admission (`killed`, `updating`, `already-leased`, `lease-id-taken`) was never stored and has no `lease.requested` (except a gateway's `lease-id-taken` after a grant, below); it carries the id minted for it before the check, the one the stored request would have had; on a gateway (ADR 0009 §4, §8) `no-worker` is rows 1 and 5 of the fast-fail table (`NO_CAPACITY`: no worker takes requests, or none that does can serve it) and `unresolvable-spec` rows 2 to 4 (`NO_DRIVER`, `UNKNOWN_MODEL`, `RUNTIME_MISSING`) and `CAPABILITY_MISSING` (a request whose `requires` no connected worker able to serve it meets with every name `available`, run on the table a second time on the capable views; ADR 0026, narrows ADR 0009), emitted in the dispatch walk before the request is queued, so a request rejected on arrival emits no `lease.queued`, and a waiting one is rejected when the views change (additive, events rule 6). A request a worker refused with one of those codes or with `CAPABILITY_MISSING` (ADR 0009 §5, ADR 0026) and the table later ends with that stored refusal gets a gateway `unresolvable-spec`, whether or not it had entered the gateway queue (the worker only declined it, ADR 0021 §4); `daemon-restarted` is a request still waiting when the daemon stopped, settled as failed when it starts again (#72; widens a published vocabulary, which events rule 6 allows as additive). The reason list can grow: a consumer must tolerate a reason it does not know; `cancelled` is an explicit single-request cancel (`cancelPending` on the leasing module, backing `DELETE /v1/lease-requests/{id}`) of a still-queued waiter -- one with device work already in flight is reported `not-cancellable` instead, the same envelope the queue timeout already uses; a physical waiter has no device work, only a presence read, so it is cancelled in any state | LeaseAcquisitionCoordinator / WaitQueue / LeaseStartup (worker) / FleetLeaseCoordinator (gateway) | implemented |
| `lease.rejected` | request id, requester (both required on every reason, additive, ADR 0016 §2), request spec (as on `lease.requested`; `full` replaced by `mode`, ADR 0007 §13; `model` optional and `class` added, ADR 0015 §9; `osVersion` may be a range as typed, ADR 0015 §2), reason (timeout/no-wait/unresolvable-spec/no-worker/already-leased/lease-id-taken/boot-timeout/killed/updating/cancelled/daemon-restarted/worker-failed), `code` and `worker` on `worker-failed` (ADR 0021 §4, additive; `worker`, never `workerId`, which marks a relayed event, ADR 0014 §6) | a request ended without a grant. A worker emits it only for its local requests; a probe ends in `lease.declined` there (ADR 0021 §2). A gateway emits it for every ending that is not a grant (ADR 0021 §4): its own reasons, `unresolvable-spec` for the last cannot-serve refusal whether or not the request had queued, and `worker-failed` for any other failure on the worker it went to -- a terminal refusal, a failure after progress, `WORKER_UNREACHABLE`, `INTERNAL`, a dispatch timeout, a worker's own `REQUESTER_ALREADY_LEASED` or `LEASE_ID_TAKEN` -- with `code` the error code the caller got and `worker` the worker. A worker's `PROVISION_FAILED` before it pushed progress is not among them (after progress it is a failure after progress, relayed as it is): the gateway walks on to the next worker, and only when every worker that could take the request has failed so does it end `worker-failed` with code `PROVISION_FAILED` and the last worker (#637). `dispose()` emits nothing, so a request still open when a gateway stops has no ending event (ADR 0021 §5 plans for usage to close those at the next `daemon.started`; not built yet). `already-leased` means the requester already has a waiting request on the same platform, or holds `lease.maxPerRequester` leases in scope (ADR 0023): the requester's leases of the request's platform when the limit is per platform, all of its leases when it is a number, where the leases it waits for count too; a worker skips the held-lease count for a probe, so it declines a probe `already-leased` only for a waiting one on its platform. A request refused at admission (`killed`, `updating`, `already-leased`, `lease-id-taken`) was never stored and has no `lease.requested` (except a gateway's `lease-id-taken` after a grant, below); it carries the id minted for it before the check, the one the stored request would have had; on a gateway (ADR 0009 §4, §8) `no-worker` is rows 1 and 5 of the fast-fail table (`NO_CAPACITY`: no worker takes requests, or none that does can serve it) and `unresolvable-spec` rows 2 to 4 (`NO_DRIVER`, `UNKNOWN_MODEL`, `RUNTIME_MISSING`) and `CAPABILITY_MISSING` (a request whose `requires` no connected worker able to serve it meets with every name `available`, run on the table a second time on the capable views; ADR 0026, narrows ADR 0009), emitted in the dispatch walk before the request is queued, so a request rejected on arrival emits no `lease.queued`, and a waiting one is rejected when the views change (additive, events rule 6). A request a worker refused with one of those codes or with `CAPABILITY_MISSING` (ADR 0009 §5, ADR 0026) and the table later ends with that stored refusal gets a gateway `unresolvable-spec`, whether or not it had entered the gateway queue (the worker only declined it, ADR 0021 §4); `daemon-restarted` is a request still waiting when the daemon stopped, settled as failed when it starts again (#72; widens a published vocabulary, which events rule 6 allows as additive). The reason list can grow: a consumer must tolerate a reason it does not know; `cancelled` is an explicit single-request cancel (`cancelPending` on the leasing module, backing `DELETE /v1/lease-requests/{id}`) of a still-queued waiter -- one with device work already in flight is reported `not-cancellable` instead, the same envelope the queue timeout already uses; a physical waiter has no device work, only a presence read, so it is cancelled in any state | LeaseAcquisitionCoordinator / WaitQueue / LeaseStartup (worker) / FleetLeaseCoordinator (gateway) | implemented |
| `lease.declined` | `{ requestId, fleetRequestId, requester, requestSpec, reason }` -- `reason` takes `lease.rejected`'s values; `requestSpec` is there for the same reason as on `lease.rejected`: a decline at admission has no `lease.requested` (ADR 0021 §2) | a worker refused or failed a probe, a `lease.request` carrying `fleetRequestId` (ADR 0021 §1), whatever the reason (`no-wait`, `unresolvable-spec`, `already-leased`, `lease-id-taken`, `boot-timeout`, `killed`, `daemon-restarted` at the next start) and however far the work got. The gateway owns the outcome of a probe, so the worker never rejects one and never queues one: where it would queue (a second failed provision) it declines and answers `PROVISION_FAILED`. The event depends only on the request being a probe, never on whether the gateway will retry. A local request is rejected exactly as before | LeaseAcquisitionCoordinator / WaitQueue / LeaseStartup (worker) | implemented |

On a **gateway**, `lease.requested`, `lease.queued` and `lease.rejected` are its own fleet queue's facts (ADR 0005 §11/§14),
Expand Down
18 changes: 18 additions & 0 deletions src/contract/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,17 @@ describe("operation input/output round trips", () => {
expect(() => OPERATIONS["lease.renew"].input.parse({ leaseId: "lse_1", ttlMs: -1 })).toThrow();
});

it("lease.cancel: keeps a platform of ios or android and refuses any other", () => {
const input = OPERATIONS["lease.cancel"].input;

expect(input.parse({ platform: "android", requesterId: "agent" })).toEqual({
platform: "android",
requesterId: "agent",
});
expect(input.parse({ platform: "ios" })).toEqual({ platform: "ios" });
expect(input.safeParse({ platform: "tvos" }).success).toBe(false);
});

it("lease.request: round-trips a representative output grant", () => {
const grant = {
device: {
Expand Down Expand Up @@ -1216,6 +1227,13 @@ describe("operation input/output round trips", () => {
stalledTransition: { thresholdMultiplier: 1, minimumThresholdMs: 1 },
};
expect(OPERATIONS["config.get"].output.parse(config)).toBeDefined();
for (const maxPerRequester of [2, { ios: 2 }, { ios: 1, android: 1 }]) {
const parsed = OPERATIONS["config.get"].output.parse({
...config,
lease: { ...config.lease, maxPerRequester },
});
expect(parsed.lease.maxPerRequester).toEqual(maxPerRequester);
}
});

it("config.get: keeps every warm target key and rejects a target with a platform or a mode the config loader would not accept", () => {
Expand Down
7 changes: 5 additions & 2 deletions src/contract/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ export const leaseCancel = defineOperation({
name: "lease.cancel",
role: "agent",
effect: "write",
input: z.object({ requesterId: z.string().optional() }),
input: z.object({ requesterId: z.string().optional(), platform: platformSchema.optional() }),
output: z.object({ result: z.enum(["cancelled", "not-found", "not-cancellable"]) }),
/**
* ADR §9: "cancels this principal's pending request by requester id". Gated on the pending
Expand All @@ -356,7 +356,10 @@ export const leaseCancel = defineOperation({
*/
authorize: (input, context) => {
if (context.role === "admin") return true;
const owner = context.pendingRequestOwner(input.requesterId ?? context.principal);
const owner = context.pendingRequestOwner(
input.requesterId ?? context.principal,
input.platform,
);
return owner === undefined || owner === context.principal;
},
});
Expand Down
2 changes: 1 addition & 1 deletion src/contract/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface ProtocolRange {
const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().int() });

/** Widen only once a second version is kept alive side by side with the first; a bump that leaves no shim behind moves both ends. */
export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 60, max: 60 };
export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 61, max: 61 };

const LEGACY_DAEMON_PROTOCOL_VERSION = 2;

Expand Down
5 changes: 4 additions & 1 deletion src/contract/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ export interface AuthorizeContext {
* Returns `undefined` for a `requesterId` with no pending request, which `lease.cancel`'s
* `authorize` hook treats as authorized on purpose -- same convention as `ownsLease` -- so
* the handler's own `not-found` surfaces instead of a misleading `FORBIDDEN`. */
readonly pendingRequestOwner: (requesterId: string) => string | undefined;
readonly pendingRequestOwner: (
requesterId: string,
platform?: "ios" | "android",
) => string | undefined;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1087,7 +1087,10 @@ export const configSchema = z.object({
}),
requestRetentionMs: z.number(),
maxRequestRecords: z.number(),
maxPerRequester: z.number(),
maxPerRequester: z.union([
z.number(),
z.object({ ios: z.number().optional(), android: z.number().optional() }),
]),
endWaitMs: z.number(),
}),
exec: z.object({ timeoutMs: z.number() }),
Expand Down
72 changes: 52 additions & 20 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,33 +713,65 @@ describe("loadConfig", () => {
).rejects.toThrow(path);
});

it("reads lease.maxPerRequester, and defaults it to 1", async () => {
it.each([
[undefined, 1],
[2, 2],
[
{ ios: 1, android: 1 },
{ ios: 1, android: 1 },
],
[{ ios: 2 }, { ios: 2 }],
[{}, {}],
])("reads an operator's lease.maxPerRequester %j as %j (absent: 1)", async (written, read) => {
const filesystem = new MemoryFilesystem();
await filesystem.mkdirp("/home/agent/.simlock");
const unset = await loadConfig({ configPath, filesystem, systemStats: createStats() });
await filesystem.writeFileAtomic(configPath, JSON.stringify({ lease: { maxPerRequester: 3 } }));
if (written !== undefined) {
await filesystem.writeFileAtomic(
configPath,
JSON.stringify({ lease: { maxPerRequester: written } }),
);
}

const set = await loadConfig({ configPath, filesystem, systemStats: createStats() });
const config = await loadConfig({ configPath, filesystem, systemStats: createStats() });

expect(unset.lease).toMatchObject({ maxPerRequester: 1 });
expect(set.lease).toMatchObject({ maxPerRequester: 3 });
expect(config.lease.maxPerRequester).toEqual(read);
});

it.each([[0], [-1], [1.5], ["2"]])(
"rejects lease.maxPerRequester %j, naming the key",
async (value) => {
const filesystem = new MemoryFilesystem();
await filesystem.mkdirp("/home/agent/.simlock");
await filesystem.writeFileAtomic(
configPath,
JSON.stringify({ lease: { maxPerRequester: value } }),
);
it.each([
[0, "lease.maxPerRequester"],
[-1, "lease.maxPerRequester"],
[1.5, "lease.maxPerRequester"],
["2", "lease.maxPerRequester"],
[{ ios: 0 }, "lease.maxPerRequester.ios"],
[{ ios: 1.5 }, "lease.maxPerRequester.ios"],
[{ ios: "1" }, "lease.maxPerRequester.ios"],
])("rejects lease.maxPerRequester %j, naming %s", async (value, path) => {
const filesystem = new MemoryFilesystem();
await filesystem.mkdirp("/home/agent/.simlock");
await filesystem.writeFileAtomic(
configPath,
JSON.stringify({ lease: { maxPerRequester: value } }),
);

await expect(
loadConfig({ configPath, filesystem, systemStats: createStats() }),
).rejects.toThrow("lease.maxPerRequester");
},
);
await expect(
loadConfig({ configPath, filesystem, systemStats: createStats() }),
).rejects.toThrow(path);
});

it("accepts a platform lease.maxPerRequester does not know, with a warning, and ignores it", async () => {
const filesystem = new MemoryFilesystem();
await filesystem.mkdirp("/home/agent/.simlock");
await filesystem.writeFileAtomic(
configPath,
JSON.stringify({ lease: { maxPerRequester: { ios: 1, tvos: 1 } } }),
);
const warn = vi.fn();

const config = await loadConfig({ configPath, filesystem, systemStats: createStats(), warn });

expect(config.lease.maxPerRequester).toEqual({ ios: 1 });
expect(warn).toHaveBeenCalledWith('Unknown config key: "lease.maxPerRequester.tvos"');
});

it("lease.endWaitMs defaults to 600000 and accepts a positive number and 2147483647", async () => {
const filesystem = new MemoryFilesystem();
Expand Down
Loading
Loading