The engine's heart. Decides which requests are batched together each tick, how to be fair across users, and how to refuse work cleanly when the system would otherwise melt.
Every request sits in exactly one of these at any time.
Why a separate prefilling queue? Prefill is expensive but parallel-friendly across the prompt's tokens. Decode is cheap per token but happens many times. Mixing them in the same logical queue makes the scheduler harder to reason about. Keeping them separate means the chunked-prefill state machine can evolve without disturbing the decode batch.
What happens on a single tick
One iteration of the scheduler's worker thread, in order. The order matters — getting it wrong drops first tokens.
Scheduling policies
Both implement the same interface. The policy decides who runs next.
Hook
When it fires
What both policies do
on_request_arrived
request enters _pending
insert into the policy's ordered structure
peek_next
admit step inspects head
return next candidate without removing it
pick_next
admit step commits
remove and return the head
on_tokens_processed
after each decode step
VTC bumps the counter; FCFS no-op
on_request_finished
row leaves _active
cleanup hook; both no-op for now
Preemption is deliberately deferred. Today's "soft hold" (a fitting request waits for room to open up) is behaviorally equivalent to preemption for the common case. Preemption only becomes load-bearing once strict priority classes or per-tenant SLAs land — see CLAUDE.md.
Backpressure (three gates)
Three independent checks decide whether a pending request can start. All three must pass.
Behavior
What it means
soft-hold
request stays at the head of _pending; we re-check next tick. Order is preserved so fairness still applies.
hard-reject
request can never fit; respond with HTTP 429 + a structured reason instead of letting it hang.
kv_pressure
used / total cache blocks. Surfaced in /scheduler/stats.
active_kv_reserved
current sum of admitted (prompt_len + max_tokens). Also surfaced.
kv_admit_blocked
counter — how many times the gates blocked an admission this run.
Chunked prefill
Long prompts no longer freeze every other user.
Prefill strategy is a config seam (PREFILL_MODE).monolithic (one forward on admit) or chunked (V-A) today; the enum is the forward-compat extension point for mixed_batch (V-B) and disaggregated (P/D) — all reuse the same _prefilling phase + prefill_chunk primitive. Empty config derives the mode from PREFILL_CHUNK_SIZE (back-compat: >0 → chunked); an unimplemented mode fails loud at construction.
The disaggregation seam: _promote_to_decode. Both monolithic admit and the chunked final-chunk path funnel a completed prefill's KV into the decode batch through this one method. Today it's a local splice on the same worker; under P/D disaggregation the KV is produced on a prefill worker and transferred here before the row decodes — so disagg adds a transfer at this single point plus a remote prefill loop, not a scheduler rewrite. The loop is already prefill/decode-decoupled, which is what makes that cheap.
Reservation accounting (the load-bearing invariant)
The rule:_active_kv_reserved is the sum of (prompt_len + max_tokens) across every row in _active. Admit adds. Finish, evict, and any future preemption all subtract. If anything forgets to subtract, the engine starts hard-rejecting requests it could actually serve.
Mask bookkeeping rule:_splice_in sizes the attention-mask cat off the mask's own width (_attention_mask.shape[1]), notbackend.kv_length(). For the paged custom backend kv_length = max(per-row len) drops when the longest row is evicted while the mask width doesn't — deriving the mask size from it cat'd mismatched widths and killed the worker (the 2026-06-12 mixed-workload crash). Scheduler-owned tensor state must be self-consistent, not derived from a backend metric whose invariants differ per backend.
See also: Backend for what decode_step_batched and prefill_chunk actually do · KV Cache for how the cache-pool gate is computed.