A waiting request says why it is not placed, on a worker
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 5/5
- 预计耗时
- 一周以上
- 新手友好度
- 12/100
- Issue 类型
- 功能
- 描述清晰度
- 基本清楚
- 活跃度
- 活跃
- 技术栈
- typescript
调研方向
Start in src/leasing/acquisition-planner.ts at the blocked return (around lines 222-224), which currently drops the capacity reason, then follow the coordinator's #defer path in src/leasing/lease-acquisition-coordinator.ts. The change touches the contract schema in src/contract/schemas.ts (which needs a protocol bump), the worker dispatcher, and the CLI and console, and the spec is part of feature #623, so confirm scope with maintainers first. Done means simlock list --requests shows a reason on queued requests only, and e2e/waiting-requests.test.ts passes with the new lines.
由索引模型根据 Issue 内容生成。
描述
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.
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" and "NO_CAPACITY detail".
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)"]
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, ... |
... — creating a device failed twice |
| 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 |
What changes
-
The plan says why it is blocked.
blocked(F7) takes a reason and returns it onwaitandno-capacity. Each path gives:Path Reason Not the head of the virtual line (F1) behind-earlier-requestWarm pool booting a fit (F3) waiting-for-deviceBoot 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-requestBoot claim already taken (F4) waiting-for-deviceTwo provisioning failures (F5) provisioning-failedPhysical, no fit free (F8) no-free-physicalThe
evict-runningandevict-managedplans also carry the capacity reason that asked for them (F6). The capacity reasons map one to one (F9). The mapping is aRecordkeyed by core'sCapacityRefusalReason, so the compiler fails when the two lists differ. -
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).- Enqueue after a second provisioning failure (F11):
provisioning-failed. - 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
reclaimingpush stays as today. - The head leaving without a wake (F34): cancelling a queued waiter, and enqueuing after a second provisioning failure, now wake the queue, so the new head plans again and replaces its
behind-earlier-request.
- Enqueue after a second provisioning failure (F11):
-
The listing shows it.
waitingRequests()addsreasonto eachqueuedentry that has one, never to astartingone (F17). The gateway's own entries get none (F18; #637 addsworkers). A gateway's list keeps thereasonon a worker's own entries (F35); the CLI prints them with the same suffix. -
NO_CAPACITYnames the capacity reason.NoCapacityErrorcarries the plan's reason. When it is a capacity reason, the worker dispatcher throwsDispatchError("NO_CAPACITY", message, { capacityReason }), as it does forLEASE_ID_TAKEN(F25). Otherwise no details, as today. So the socket sends it (F24), and HTTP's 503 body carriescapacityReason(F24, F26). A replay answers no details, as today (F32). -
CLI. A
queuedline with a reason ends—plus the reason text with only its first letter lower-cased. The text comes from feature #623 Shared values "Reason text"; the platform word is the request's (iOS,Android). -
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).
-
Two text tables, each checked. The CLI keeps its table in
src/cli, the console inui/src/model(F30). Each side has one test that its table covers every reason the contract's enum lists.
Modules touched
Searches: waitingRequestSchema, WaitingRequest, places(, blocked(, NoCapacityError, "NO_CAPACITY", CapacityRefusalReason, formatWaitingRequests, WaitingRow.
src/contract/schemas.ts,src/contract/errors.ts,src/contract/protocol.tssrc/core/index.ts(exportsCapacityRefusalReason)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.tssrc/daemon/dispatcher.tssrc/cli/index.tsui/src/model/waiting.ts,ui/src/screens/waiting/index.tsxe2e/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; manualssrc/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)"]
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, ... }]
Contract and event changes
waitingRequestSchemagainsreason?: enumof the eight values in feature #623 Shared values "Waiting reason (worker)". A value outside the enum fails the parse, and so doesreasonon astartingentry.ErrorDetailsMap.NO_CAPACITYbecomes{ capacityReason?: enum of the four };NO_CAPACITYjoinsCODES_WITH_DECLARED_DETAILS.- This PR bumps the protocol by one,
minwith it (F31; feature #623 Shared values "NO_CAPACITY detail"). - Events: none.
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.
- Warm pool
waitingDemand(lease-acquisition-coordinator.ts:621-635) and#heldByOlder(:729-738): read spec and state, not the reason. No conflict. cancelPending(:609-619): rejects a queued waiter; it leaves the list (F17), and now wakes the queue (F34). A wake only plans the head and physical waiters under the lock, as every wake does.- The gateway (F18): shares the book and queue code. Its own entries carry no
reason; a worker's entries keep theirs (F35). - #637: adds
workersto the same schema and readsdetails.capacityReason. #637 depends on this task. - #629, #631, #635 each bump the protocol. The PR that merges second bumps again.
- #633: adds the wait p95 to the Waiting page. Other lines of the same screen; whichever merges second takes the base branch.
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, F11, F8) | NO_CAPACITY, no details |
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.md10: "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.md16: "Policy is a pure function". The planner returns the reason; it stores nothing.architecture.md8: "The daemon owns all state; the CLI is a thin client." The CLI only prints the field.safety.md10: "Anything that arrives over the wire is a claim".reasonandcapacityReasonare closed enums in the contract.src/contract/AGENTS.md: "Every change to a contract shape bumps it by one in the same PR."testing.md6, 7: throughcreateLeasing, and the planner's own test for its many cases.documentation.md3: 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 refusedram-budget,global-running-limit,platform-running-limit; provision refuseddevice-limit,ram-budget,global-running-limit,platform-running-limit; victim claim taken atdevice-limitand atplatform-running-limit, with the eviction plans carrying it; physical fits held only for an older requestbehind-earlier-request; boot claim takenwaiting-for-device; two failuresprovisioning-failed; physicalno-free-physical. Extend "waits or rejects when policy cannot reserve capacity" (acquisition-planner.test.ts:211). - The same reasons come back on
no-capacityundernoWait. - A queued request at the iOS running limit lists
reason: "platform-running-limit"; a second one behind it listsbehind-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 request re-queued after its second provisioning failure lists
provisioning-failed, and the request behind it, now head, plans again and lists its own reason. - 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
startingrequest lists no reason: one being provisioned, and one held at its first plan. - A gateway's own queued entry lists no
reason: extendsrc/gateway/fleet-coordinator.test.ts:4309. A gateway'slist.get requestskeeps a worker's own entry'sreason: extendsrc/gateway/dispatcher.test.ts:1436. - Dispatcher: a
noWaitlease.requestrefused at the RAM budget, at the device limit and at the platform running limit rejectsNO_CAPACITYwithdetails.capacityReasonof each; one behind an earlier request rejectsNO_CAPACITYwith no details; a replay of the RAM-budget request by its idempotency key rejectsNO_CAPACITYwith no details. Model onsrc/daemon/dispatcher.test.ts:473. - HTTP:
POST /v1/lease-requestswithnoWaitat the running limit answers 503 withcapacityReasonin the error body (src/http/app.test.ts). - Contract:
waitingRequestSchemaaccepts each of the eight reasons on aqueuedentry, and refuses"bogus"and any reason on astartingentry;NO_CAPACITYis inCODES_WITH_DECLARED_DETAILS(src/contract/schemas.test.ts,errors.test.ts). - CLI: a queued request prints
— <text>for every reason,platform-running-limitonce 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. Extendsrc/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 inui/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.maxDevices2,ios.maxRunning1, globalmaxRunning2 and one iOS lease held, a secondsimlock leasewaits;simlock list --requestsprints 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.requestwithnoWait: trueover the socket is the head, and answersNO_CAPACITYwithdetails.capacityReason: "platform-running-limit". docs/CLI.md,docs/HTTP-API.md,docs/CONSOLE.mdanddocs/CLIENT.mddescribe the reason and the detail.
Out of scope
- A gateway's own entries and per-worker reasons (#637).
- A reason on a
startingrequest. - Keeping reasons across a restart, or in events.
- Waiting only behind same-platform requests (#387).
- The gateway's own
NO_CAPACITYcarrying a capacity reason.
Depends on
- #635
Written by an agent.
- 主要语言
- TypeScript
- 星标
- 19
- 派生
- 1
- 平均合并
- 7 小时 49 分钟
- 30 天内合并 PR
- 228
环境准备
这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
callstackincubator/simlock 的其他 Issue
-
难度 2/5 1-3 小时 新手友好度 70/100
callstackincubator/simlock#639 ·
维护者通常 1 天内回复
-
Lint does not enforce index.ts boundaries for cleanup/, routing/, warm-pool/ and contract/ helpers可能已有人在做 @V3RON 今天认领。 未关闭bug:ready
难度 2/5 1-3 小时 新手友好度 70/100
callstackincubator/simlock#445 · 1 条评论 · 已指派 1 人 ·
维护者通常 1 天内回复
-
daemon-lifecycle e2e: the it.fails status test passes for the wrong reason可能已有人在做 @V3RON 于 8 天前认领。 未关闭bug:triage
难度 2/5 1-3 小时 新手友好度 84/100
callstackincubator/simlock#422 · 1 条评论 · 已指派 1 人 ·
维护者通常 1 天内回复
-
task:ready
难度 5/5 一周以上 新手友好度 8/100
callstackincubator/simlock#637 ·
维护者通常 1 天内回复
-
task:ready
难度 5/5 一周以上 新手友好度 12/100
callstackincubator/simlock#635 ·
维护者通常 1 天内回复
查看 callstackincubator/simlock 的全部 Issue
相似的 Issue
-
bnv-manager-v2: accept_invite creates the Mailcow domain admin before the commit可能已有人在做 @myOmikron 今天认领。 未关闭bug ready-for-agent
难度 2/5 1-3 小时 新手友好度 82/100
-
platform:windows tool triaged wontfix
难度 2/5 1-3 小时 新手友好度 62/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 82/100
nicolettas-muggelbude/RechnungsFee#448 ·
维护者通常 1 天内回复
-
Android bug feature/error-tracking react-native team/error-tracking
难度 2/5 1-3 小时 新手友好度 72/100
PostHog/posthog-js#5310 · 1 条评论 ·
维护者通常 1 天内回复