Skip to content

A waiting request says why it is not placed, on a worker #636

Closed

Description

@V3RON

Part of #623.

Scope

On a single host (a worker), a request waiting in the queue says why it is not placed yet: in simlock list --requests, GET /v1/lease-requests, status.get waiting, and a "Why" row in the console's Waiting inspector. A worker's NO_CAPACITY answer names the capacity check that refused it, so #637 can show it on a gateway.

A request whose new device fails to provision twice no longer waits: it is rejected with a new error code PROVISION_FAILED carrying the driver's error (decided by the maintainer, 2026-10-18).

In short

Each time the daemon tries to place a waiting request, it keeps the reason the attempt stopped, in memory. A queued request shows the reason from its latest attempt. A starting request shows none: its stage already says the daemon is working on it. The reasons and their texts are feature #623 Shared values, rows "Waiting reason (worker)", "Reason text", "NO_CAPACITY detail" and "PROVISION_FAILED" (decided by the maintainer, 2026-10-18).

flowchart LR
  A[Request arrives or queue wakes] --> P{Plan}
  P -- grant / boot / create / evict --> S[starting]
  P -- blocked --> R["Keep the reason on the waiter (new)"]
  R --> Q[queued]
  Q --> L["list.get requests: reason on the entry (new)"]
  P -- blocked, noWait --> N["NO_CAPACITY with details.capacityReason (new)"]
Loading

Words used:

  • Reason: the first check that stopped the request at its latest placement attempt, for example platform-running-limit, printed "Full: running limit for iOS".
  • Capacity reason: one of the four capacity refusals (device-limit, ram-budget, global-running-limit, platform-running-limit).

Before and after:

$ simlock list --requests
Request req_7: agent-b, ios iPhone 16, queued at 1, waiting 12s
Request req_9: agent-c, ios iPhone 16, queued at 2, waiting 3s
Request req_11: agent-d, android Pixel 8, starting, waiting 1s

$ simlock list --requests          # after
Request req_7: agent-b, ios iPhone 16, queued at 1, waiting 12s — full: running limit for iOS
Request req_9: agent-c, ios iPhone 16, queued at 2, waiting 3s — behind an earlier request
Request req_11: agent-d, android Pixel 8, starting, waiting 1s
Situation Today After
iOS running limit reached, nothing idle to shut down queued at 1, waiting 12s ... — full: running limit for iOS
Another request is ahead in the simulator/emulator line queued at 2, ... ... — behind an earlier request
Creating a device failed twice queued at 1, ... rejected: PROVISION_FAILED with the driver's error (decided by the maintainer, 2026-10-18)
The warm pool is booting a device that fits queued at 1, ... ... — a matching device is starting
Physical: every match leased or absent physical ..., queued at 1, ... ... — every matching device is leased or absent
Being placed starting, waiting 1s as today
--no-wait at the RAM budget NO_CAPACITY, no details NO_CAPACITY, details.capacityReason: "ram-budget"

If this goes wrong, a user would see a reason that is stale or wrong, or a reason on a request that is starting.

Technical spec

Facts

# Fact Source
F1 Only the head of the virtual line plans. Any other virtual waiter gets wait, or no-capacity under noWait, without asking the planner. src/leasing/lease-acquisition-coordinator.ts:891-907
F2 A queue wake drives the head of the virtual line and every pending physical waiter, nobody else. src/leasing/lease-acquisition-coordinator.ts:1171-1181
F3 The planner waits, outside noWait, when the warm pool is booting or creating a fitting device for nobody. src/leasing/acquisition-planner.ts:107-116, :139-143
F4 A refused boot of a shut-down device with no idle device to evict is blocked; so is a boot whose claim is taken. src/leasing/acquisition-planner.ts:145-164
F5 A refused new device is blocked when there is no victim; at device-limit it first tries a managed victim; two failures block a provision that capacity allows. src/leasing/acquisition-planner.ts:166-187
F6 A taken eviction claim is blocked. The two eviction plans carry only a claim and a device, no reason. src/leasing/acquisition-planner.ts:67-76, :189-197
F7 blocked returns wait or no-capacity and drops the capacity reason it was given. src/leasing/acquisition-planner.ts:222-224
F8 A physical plan with no ready, present, free fit is blocked; a fit held for an older queued physical request is skipped. src/leasing/acquisition-planner.ts:128-137, :132; src/leasing/lease-acquisition-coordinator.ts:729-738
F9 The capacity refusal reasons are device-limit, ram-budget, global-running-limit, platform-running-limit. src/core/capacity/strategy.ts:30-34
F10 A blocked plan reaches #defer, which rejects a noWait waiter with NoCapacityError and enqueues any other. src/leasing/lease-acquisition-coordinator.ts:882-886, :1071-1078, :1239-1246
F11 After a first provisioning failure the waiter plans again at once; after a second it is enqueued without a plan, or a probe is declined with NoCapacityError. src/leasing/lease-acquisition-coordinator.ts:842-857
F12 A failed eviction (shutdown or delete threw) defers the waiter with no plan. src/leasing/lease-acquisition-coordinator.ts:948-960, :978-984
F13 A physical waiter with no plan defers; a failed physical grant defers too. src/leasing/lease-acquisition-coordinator.ts:718-721, :774-776
F14 A queued waiter whose spec matches a reclaiming device gets a reclaiming progress push; its plan is untouched. src/leasing/lease-acquisition-coordinator.ts:1080-1101
F15 NoCapacityError carries no fields. src/leasing/lease-acquisition-coordinator.ts:82-87
F16 Waiter state lives in memory only; a daemon start fails every request the previous process left open. src/leasing/AGENTS.md "State and timers", "Rules"
F17 waitingRequests() is the request book's waiting(queue.places()); places() gives a queuePosition only to a queued waiter, and the book maps that to stage queued, else starting. src/leasing/create-leasing.ts:280, src/leasing/wait-queue.ts:169-175, src/leasing/lease-request-book.ts:67-90, :308-313
F18 The gateway builds its own entries from the same book and its own WaitQueue. src/gateway/fleet-coordinator.ts:208-210
F19 waitingRequestSchema has no reason. It types list.get requests, status.get waiting, and a worker view's waiting. src/contract/schemas.ts:311-319, :1158; src/contract/operations.ts:123, :584
F20 The daemon parses each handler result through the output schema; an undeclared field is stripped. src/contract/AGENTS.md "Rules"
F21 The worker answers list.get requests and status.get waiting from queue.waitingRequests(). src/daemon/dispatcher.ts:380, :754
F22 GET /v1/lease-requests returns list.get requests as is; the console reads that route. src/http/app.ts:308-315, ui/src/state/waiting.ts:29
F23 NO_CAPACITY declares no details, and is not in CODES_WITH_DECLARED_DETAILS. src/contract/errors.ts:37, :148-175
F24 The socket sends details only from a DispatchError. HTTP puts them in the body only for a DispatchError whose code is in CODES_WITH_DECLARED_DETAILS. src/daemon/server.ts:843-853, src/http/errors.ts:154-170
F25 The worker dispatcher already turns a leasing error into a DispatchError with details: LEASE_ID_TAKEN with leaseId. src/daemon/dispatcher.ts:493-495
F26 POST /v1/lease-requests with noWait answers a refusal through errorResponse. src/http/app.ts:294-300
F27 fromWireError passes details through unchecked. src/contract/errors.ts:453-466
F28 The CLI parses the list with waitingRequestSchema and prints one line per request ending waiting <n>s. src/cli/index.ts:975-976, :987-1010
F29 The console's Waiting inspector shows cards for Waiting, Place, Stage, then facts Request, Created, Queue. A stage it does not know shows as its raw word. ui/src/screens/waiting/index.tsx:108-142, ui/src/model/waiting.ts:104-124, ui/src/model/tones.ts:44, ui/src/model/waiting.test.ts:92
F30 The console's model/ imports contract types only. ui/AGENTS.md:5, :20
F31 Every contract shape change bumps the protocol by one in the same PR; the range is { min: 39, max: 39 }. docs/internal/agent-rules/toolchain.md:153-156, src/contract/AGENTS.md:58-60, src/contract/protocol.ts:13
F32 A replayed failed request answers its stored code only. src/daemon/error-code.ts:90-94
F34 Cancelling a queued waiter rejects it and wakes nobody; so does enqueuing after a second provisioning failure. A queue timeout kicks the queue. src/leasing/lease-acquisition-coordinator.ts:609-619, :842-857; src/leasing/create-leasing.ts:122-135
F35 The gateway lists a worker's own entries spread from its view, so every field the schema declares is kept. src/gateway/dispatcher.ts:387-397
F36 The e2e test anchors each line at waiting \d+s$: single host at :46, :53; gateway at :91, :96, :98. Its limits are one iOS device, one running. e2e/waiting-requests.test.ts:13, :37-101
F37 Core's index re-exports capacity helpers but not CapacityRefusalReason, which only src/core/capacity/index.ts exports. src/core/index.ts:34-38, src/core/capacity/index.ts:21
F33 Docs that describe the line and the route today. docs/CLI.md:1562-1580, docs/HTTP-API.md:1529-1561, docs/CONSOLE.md:289-313
F38 Exit codes group errors by class; 1 is internal or a failure the caller cannot fix by asking differently (INTERNAL, DAEMON_STARTUP_FAILED, WORKER_UNREACHABLE); 14 and 15 are taken by lease and component install outcomes. docs/CLI.md:84-118, src/contract/errors.ts:226-386 (decided by the maintainer, 2026-10-18)
F39 The error tables users read: the CLI's exit codes and HTTP's status table. docs/CLI.md:75-96, docs/HTTP-API.md:1758 (decided by the maintainer, 2026-10-18)

What changes

  1. The plan says why it is blocked. blocked (F7) takes a reason and returns it on wait and no-capacity. Each path gives:

    Path Reason
    Not the head of the virtual line (F1) behind-earlier-request
    Warm pool booting a fit (F3) waiting-for-device
    Boot refused, no victim (F4) the refusal's capacity reason
    New device refused, no victim (F5) the refusal's capacity reason
    Victim found, its claim taken (F5, F6) the capacity reason that asked for the eviction
    Physical, only fits held for an older request (F8) behind-earlier-request
    Boot claim already taken (F4) waiting-for-device
    Physical, no fit free (F8) no-free-physical

    The evict-running and evict-managed plans also carry the capacity reason that asked for them (F6). The capacity reasons map one to one (F9). The mapping is a Record keyed by core's CapacityRefusalReason, so the compiler fails when the two lists differ.

  2. The waiter keeps it. Every enqueue sets the waiter's reason from the plan that led to it. Each new plan replaces it. It is never written to state.json (F16).

    • Failed eviction (F12): the capacity reason that asked for the eviction.
    • Failed physical grant (F13): no-free-physical.
    • A request whose match is being cleaned (F14) keeps the capacity reason its plan gave; the reclaiming push stays as today.
    • The head leaving without a wake (F34): cancelling a queued waiter now wakes the queue, so the new head plans again and replaces its behind-earlier-request (decided by the maintainer, 2026-10-18).
  3. The listing shows it. waitingRequests() adds reason to each queued entry that has one, never to a starting one (F17). The gateway's own entries get none (F18; A waiting request says why each worker cannot take it, on a gateway #637 adds workers). A gateway's list keeps the reason on a worker's own entries (F35); the CLI prints them with the same suffix.

  4. NO_CAPACITY names the capacity reason. NoCapacityError carries the plan's reason. When it is a capacity reason, the worker dispatcher throws DispatchError("NO_CAPACITY", message, { capacityReason }), as it does for LEASE_ID_TAKEN (F25). Otherwise no details, as today. So the socket sends it (F24), and HTTP's 503 body carries capacityReason (F24, F26). A replay answers no details, as today (F32).

  5. CLI. A queued line with a reason ends — plus the reason text with only its first letter lower-cased. The text comes from feature Operational metrics kept over hours and shown in the console #623 Shared values "Reason text"; the platform word is the request's (iOS, Android).

  6. Console. The inspector gains a "Why" fact after Queue, with the reason text, for an entry with a reason. No row otherwise. A reason the console does not know shows as its raw word, as a stage does (F29).

  7. Two text tables, each checked. The CLI keeps its table in src/cli, the console in ui/src/model (F30). Each side has one test that its table covers every reason the contract's enum lists.

  8. A second provisioning failure rejects. A first failure still plans again at once, as today (F11). A second no longer enqueues: the waiter is rejected with PROVISION_FAILED, kind domain, HTTP 500, exit code 1 (F38), whose message is the driver's error. A probe (fleetRequestId set) that fails twice is declined with PROVISION_FAILED too, not NO_CAPACITY. The rejection wakes the queue, as the boot-timeout and component-being-removed rejections do (src/leasing/lease-acquisition-coordinator.ts:828-845), so the next waiter plans. Those two stay as they are (decided by the maintainer, 2026-10-18).

Modules touched

Searches: waitingRequestSchema, WaitingRequest, places(, blocked(, NoCapacityError, "NO_CAPACITY", CapacityRefusalReason, formatWaitingRequests, WaitingRow.

  • src/contract/schemas.ts, src/contract/errors.ts, src/contract/protocol.ts
  • src/core/index.ts (exports CapacityRefusalReason)
  • src/leasing/acquisition-planner.ts, src/leasing/lease-acquisition-coordinator.ts, src/leasing/lease-request-book.ts, src/leasing/wait-queue.ts, src/leasing/create-leasing.ts
  • src/daemon/dispatcher.ts
  • src/cli/index.ts
  • ui/src/model/waiting.ts, ui/src/screens/waiting/index.tsx
  • e2e/waiting-requests.test.ts: its five line patterns (F36) gain the reason suffix, and the single-host test pins the limits below
  • Docs: docs/CLI.md, docs/HTTP-API.md, docs/CONSOLE.md, docs/CLIENT.md; manuals src/leasing/AGENTS.md, src/contract/AGENTS.md
flowchart LR
  cli["CLI (changed)"] --> d["worker dispatcher (changed)"]
  http[HTTP] --> d
  ui["console Waiting (changed)"] --> http
  d --> lease["leasing: coordinator, book (changed)"]
  lease --> plan["planner (changed)"]
  plan --> cap[capacity]
  d --> contract["contract (changed)"]
Loading
sequenceDiagram
  participant G as Gateway or client
  participant D as Worker dispatcher
  participant C as Coordinator
  participant P as Planner
  G->>D: lease.request noWait
  D->>C: request
  C->>P: plan
  P-->>C: no-capacity, ram-budget
  C-->>D: NoCapacityError(ram-budget)
  D-->>G: NO_CAPACITY, details.capacityReason ram-budget
  G->>D: list.get requests
  D-->>G: [{ stage: queued, reason: platform-running-limit, ... }]
Loading

Contract and event changes

Other code on the same state

Searches: readers of queue.pending(), queue.list(), .places() outside tests (4 hits in src/leasing, 5 in src/gateway); writers of waiter fields in lease-acquisition-coordinator.ts.

Failure modes

Call Outcome What happens Limit, counted from
list.get requests ok Each queued entry with its latest reason as today
list.get requests client on another protocol PROTOCOL_VERSION_UNSUPPORTED at hello, as today; existing hello tests cover it as today
lease.request noWait capacity refusal NO_CAPACITY with details.capacityReason none
lease.request noWait other refusal (F1, F8) NO_CAPACITY, no details none
lease.request, noWait or not, probe included second provisioning failure PROVISION_FAILED with the driver's message; nothing listed; the queue wakes (decided by the maintainer, 2026-10-18) none
lease.request noWait replayed by idempotency key stored code, no details (F32) none
Console GET /v1/lease-requests unknown reason word raw word in the Why row none
  • Restart mid-step: the reason is in memory; the restart fails the request (F16). Nothing left behind; no test needed.
  • Two callers at once: a listing reads the waiter's reason as the last plan left it. One thread; no torn read; no test needed.

Rules in play

  • architecture.md 10: "A rule is enforced in exactly one place." The reason comes from the plan; each text table is checked against the contract's enum by a test; the capacity list is checked by the compiler.
  • architecture.md 16: "Policy is a pure function". The planner returns the reason; it stores nothing.
  • architecture.md 8: "The daemon owns all state; the CLI is a thin client." The CLI only prints the field.
  • safety.md 10: "Anything that arrives over the wire is a claim". reason and capacityReason are closed enums in the contract.
  • src/contract/AGENTS.md: "Every change to a contract shape bumps it by one in the same PR."
  • testing.md 6, 7: through createLeasing, and the planner's own test for its many cases.
  • documentation.md 3: no repo path in what the CLI prints.

Tests

Seam: createLeasing through createTestEngine (src/leasing/testing.ts) and its waitingRequests(), which list.get requests returns (F21); model on src/leasing/create-leasing.test.ts:3757. The planner's cases in src/leasing/acquisition-planner.test.ts.

  • Planner: each blocked path returns its reason: not tried by the planner; warm-pool boot waiting-for-device; boot refused ram-budget, global-running-limit, platform-running-limit; provision refused device-limit, ram-budget, global-running-limit, platform-running-limit; victim claim taken at device-limit and at platform-running-limit, with the eviction plans carrying it; physical fits held only for an older request behind-earlier-request; boot claim taken waiting-for-device; physical no-free-physical. Extend "waits or rejects when policy cannot reserve capacity" (acquisition-planner.test.ts:211).
  • The same reasons come back on no-capacity under noWait.
  • A queued request at the iOS running limit lists reason: "platform-running-limit"; a second one behind it lists behind-earlier-request; the stored request record has no reason.
  • The reason follows the latest attempt: when the head is granted, the request behind it becomes head and lists its own new reason, not behind-earlier-request.
  • A first provisioning failure still plans again at once, and a success then grants (decided by the maintainer, 2026-10-18).
  • A second provisioning failure rejects the request with PROVISION_FAILED and the driver's message; nothing stays listed for it; the request behind it plans again and lists its own reason (decided by the maintainer, 2026-10-18).
  • A probe (fleetRequestId set) whose provisioning fails twice answers PROVISION_FAILED, not NO_CAPACITY (decided by the maintainer, 2026-10-18).
  • Cancelling the queued head makes the request behind it plan again and list its own reason, not behind-earlier-request.
  • A request while the warm pool boots a fitting device lists waiting-for-device.
  • A failed eviction lists the capacity reason that asked for it; a failed physical grant lists no-free-physical; a request whose only match is reclaiming lists the planner's capacity reason.
  • A waiting physical request with every fit leased lists no-free-physical (physical-leasing.test.ts:582).
  • A starting request lists no reason: one being provisioned, and one held at its first plan.
  • A gateway's own queued entry lists no reason: extend src/gateway/fleet-coordinator.test.ts:4309. A gateway's list.get requests keeps a worker's own entry's reason: extend src/gateway/dispatcher.test.ts:1436.
  • Dispatcher: a noWait lease.request refused at the RAM budget, at the device limit and at the platform running limit rejects NO_CAPACITY with details.capacityReason of each; one behind an earlier request rejects NO_CAPACITY with no details; a replay of the RAM-budget request by its idempotency key rejects NO_CAPACITY with no details. Model on src/daemon/dispatcher.test.ts:473.
  • HTTP: POST /v1/lease-requests with noWait at the running limit answers 503 with capacityReason in the error body (src/http/app.test.ts).
  • Contract: waitingRequestSchema accepts each of the seven reasons on a queued entry, and refuses "bogus" and any reason on a starting entry; NO_CAPACITY is in CODES_WITH_DECLARED_DETAILS (src/contract/schemas.test.ts, errors.test.ts).
  • Contract: PROVISION_FAILED is kind domain, HTTP 500, exit code 1 (errors.test.ts) (decided by the maintainer, 2026-10-18).
  • CLI: a queued request prints — <text> for every reason, platform-running-limit once for iOS and once for Android, only the first letter lower-cased; a queued request with no reason and a starting one print as today. Extend src/cli/index.test.ts:6284.
  • CLI and console tables: each side's table has a text for every reason the contract's enum lists (one test in src/cli, one in ui/src/model).
  • Console model: the chosen row's Why is the text for every reason, the raw word for an unknown one, and absent with no reason (ui/src/model/waiting.test.ts).
  • Console screen: the inspector shows a Why fact after Queue with the text, and no Why fact for a starting request (ui/src/screens/waiting/waiting.test.tsx:76).
  • E2E, single host (e2e/waiting-requests.test.ts:37-55), limits { ios: { maxDevices: 2, maxRunning: 1 }, maxRunning: 2 }, one lease held: agent-b's line ends — full: running limit for iOS, agent-c's — behind an earlier request.
  • E2E, gateway (:57-101): the worker's local-b line ends — full: device limit, on the worker and on the gateway; the fleet line is unchanged.

Done when

  • The tests above pass.
  • On the fake driver, with ios.maxDevices 2, ios.maxRunning 1, global maxRunning 2 and one iOS lease held, a second simlock lease waits; simlock list --requests prints its line ending — full: running limit for iOS (e2e/waiting-requests.test.ts:37-55), and the console's Waiting inspector shows the same text under Why.
  • With the same limits, one iOS lease held and no request waiting, lease.request with noWait: true over the socket is the head, and answers NO_CAPACITY with details.capacityReason: "platform-running-limit".
  • docs/CLI.md, docs/HTTP-API.md, docs/CONSOLE.md and docs/CLIENT.md describe the reason and the detail.
  • The error tables in docs/CLI.md, docs/HTTP-API.md and docs/CLIENT.md list PROVISION_FAILED (F39) (decided by the maintainer, 2026-10-18).

Out of scope

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 16, 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 16, 2026
  3. self-assigned this
    on Oct 18, 2026
  4. V3RON commented on Oct 18, 2026

    @V3RON
    ContributorAuthor

    Handoff

    Done: PR #692 is green locally at 25dd426 (616/616 leasing tests). It has two review rounds and three fix-checks. R1–R5, X1, X2, X4 and X5 are fixed and verified.

    Not done: F1. The PR stays a draft and is not in the stack yet.

    Findings: F1 (blocking). src/leasing/lease-acquisition-coordinator.ts:1192-1199: a wake that does not follow a failure drives only queue.head. When two or more waiters have each failed two provisions, a release that fits the second one never places it. There is no test with more than one twice-failed waiter. Fix it the same way as the earlier X1/X3 fixes: drive the virtual waiters in line order and skip only the request that just failed.

    Blocked on: the maintainer. The decision taken in delivery (PR comment "Decision taken": a twice-failed request is not replanned by the wake that follows its second failure) keeps producing edge cases. Either confirm it, so F1 can be fixed as above, or choose the base behaviour, where every wake replans the head.

    Written by an agent.

  5. added
    task:blockedAgent stopped on a blocker in its handoff. Maintainer re-adds the label it came from.
    and removed
    task:readySpec checked. An agent may implement it once its Depends on allow.
    on Oct 18, 2026
  6. removed their assignment
    on Oct 18, 2026
  7. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:blockedAgent stopped on a blocker in its handoff. Maintainer re-adds the label it came from.
    on Oct 18, 2026
  8. self-assigned this
    on Oct 18, 2026
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