daemon: propose ADR 0011 — admission control vs. the segment budget

Settles the open interface question in AGENT-DAEMON.md before sched/ is
written: DAEMON's concurrency governor (global/per-queue/per-host, task
units) and CORE's maxActiveSegments (segment units) are two governors on
two axes with non-overlapping enforcement — each lane enforces exactly
the ceilings counted in the units it owns, with one narrow task-unit
clamp against maxActiveSegments. Records the fairness rule DAEMON needs
from CORE (min-1-before-seconds) so admission implies progress even
when one download could otherwise hold the entire segment budget.

Companion daemon/docs/core-requests-m1.md is the concrete engine API
ask (budget()/segments_active()/on_budget_changed, live-apply semantics
for set_max_active_segments, set_host_segment_cap, probe pool sizing)
plus one contract gap for PROTO (connection.maxActiveSegments missing
from Settings.schema.json).

Status: proposed, pending CORE sign-off on the five open items at the
end of the ADR. daemon/src/sched/ does not land until that lands.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
This commit is contained in:
2026-09-09 23:20:09 +04:00
co-authored by Claude Sonnet 5
parent a5ac817f01
commit ecedfac903
2 changed files with 260 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
# DAEMON → CORE — the engine API `sched/` needs before it can be written
Status: **open**. Companion to `docs/adr/0011-admission-control-and-the-segment-budget.md`,
which settles *who* enforces what. This doc is the concrete API ask that ADR depends on.
Also carries one contract gap (C4) that is PROTO's, not CORE's, filed here because it was
found while writing the ADR.
Ranking follows `contracts/README.md` rule 4 conventions even though this isn't a
`contracts/` change: new API surface = cheap, land anytime; a behavioural promise (min-1
fairness) = needs CORE's explicit sign-off before DAEMON builds on the assumption.
---
## C1. Occupancy read-out, not inference
DAEMON's governor (ADR 0011 §1) must never count segments to make an admission decision,
which means it needs to *read* engine occupancy rather than derive it from how many tasks
it thinks it started. Requesting:
- `EngineBudget budget() const``{ total: uint32, active: uint32, tasks_starved: uint32 }`
where `tasks_starved` is the count of running tasks currently holding zero segments
(ADR 0011 §3.6 — DAEMON asserts this is 0 in steady state and logs if it isn't).
- `uint32_t segments_active(TaskId) const` — for `TaskSummary.segments` projection
(ADR 0010: the *effective* count) and for `velox ls --json`.
- A coalesced `on_budget_changed(callback)`, batched at the same ≤4 Hz as
`event.task.progress` (brief item 5) — DAEMON is not polling this on a tighter loop.
## C2. `set_max_active_segments(uint32_t)` and its live-apply semantics
Brief already has this as a settings value CORE enforces. DAEMON needs to know: does
lowering it live drain existing segments down to the new ceiling (segments finish, no new
ones start until under budget), or does it kill in-flight segments? DAEMON's assumption,
pending your answer, is drain-not-kill — a live cut to 8 must not abort 24 in-flight
segments and lose their partial ranges. ADR 0011 open item 3.
## C3. `set_host_segment_cap(host, uint32_t)`
The per-host table is DAEMON state (SQLite `settings`/a host-cap table); CORE enforces it
in segment units. Requesting a push API so there is one source of truth for the table and
two enforcement points, per ADR 0011 §4. DAEMON derives its own per-host **task** cap from
the same table value — same number, different unit, pushed once.
## C4. Contract gap: `connection.maxActiveSegments` isn't in `Settings.schema.json`
Not CORE's item — flagging because ADR 0011 depends on it and `core/docs/buffer-sizing.md`
already asked for it. `SettingKey` is a closed enum (`additionalProperties: false`), so
DAEMON cannot expose this key through `settings.get`/`settings.set` until PROTO lands it.
Until then DAEMON holds the value (default 32) locally and passes it to
`set_max_active_segments()` at startup only — no wire exposure, so `Options → Connection`
can't show or set it yet. Same ask as CORE's B2a bundle; landing them together is fine.
## C5. Fairness rule sign-off (ADR 0011 §3)
Not new API — a behavioural contract. DAEMON's governor is built assuming:
1. Min-1 before seconds: no running task gets a second slot while another running task
has zero.
2. Admission implies progress, subject only to connect timeout and per-host cap.
3. A steal is slot-neutral.
4. Priority order for slot distribution beyond the first comes from DAEMON (queue order,
then FIFO by admission time, unless you'd rather take an explicit ordered list per tick
— ADR 0011 open item 4).
If any of this can't be built as stated inside the stealer, say so before `sched/` exists —
this is the one thing in this doc that changes DAEMON's design rather than just its
plumbing.
## C6. Probe pool sized outside the segment budget
ADR 0011 §5: `download.probe` must not spend a segment slot, and `capture.offer`'s 750 ms
deadline can't wait on the segment budget being free. Confirming CORE's probe path already
runs its own small worker pool (proposed size 4) independent of `maxActiveSegments` — if
not, this is a request to make it so.
---
Raise objections against ADR 0011 directly; this doc is downstream of it and changes if
the ADR does.
@@ -0,0 +1,182 @@
# ADR 0011 — Admission control, the segment budget, and who counts what
**Status:** proposed · **Date:** 2026-09-09 · **Lane:** DAEMON, needs CORE sign-off
**Companion request:** `daemon/docs/core-requests-m1.md` (the engine API this depends on)
**Blocks:** `daemon/src/sched/` — no scheduler code lands before this is accepted.
## Context
Two lanes are each building a global concurrency governor, neither brief mentions the
other, and they are counting different things.
* **CORE** is adding `maxActiveSegments` (default 32) inside the engine — a ceiling on
segments actually transferring at once. It is the mechanism that makes the RSS target in
`docs/04` §8 hold (`core/docs/buffer-sizing.md`), so it is not optional.
* **DAEMON** is briefed to build "a concurrency governor (global max active, per-queue max,
per-host caps)", backed by `connection.maxConcurrentDownloads`, `Queue.maxConcurrent`,
schedules and queue order.
Left alone this lands in one of two states, and both are bad in a way that is hard to
diagnose after the fact:
1. **Double-throttling.** Both lanes enforce a global ceiling, so the effective limit is
the minimum of two numbers the user set independently. The link runs at half rate and
it reads as a performance bug in the engine, not as a policy collision.
2. **The gap.** Each lane assumes the other holds the line. Nobody does, 20 downloads open
160 connections, and the RSS budget that `maxActiveSegments` exists to defend is gone.
There is a third problem underneath both. With `maxActiveSegments = 32` and
`connection.maxSegmentsPerDownload` capped at 32, one download can hold the entire segment
budget. If the daemon admits a second task and the engine has no slot to give it, the task
is *running* and transferring nothing: every DAEMON assumption that admission implies
progress — stall detection, speed accounting, queue drain, "when queue completes" — is
then wrong. CORE owns that fairness rule. DAEMON cannot write a governor without knowing
what it is.
## Decision
### 1. Every ceiling is enforced exactly once, by the lane that owns the unit it counts
This is the whole ADR in one line. The two governors stay two governors, on two axes, with
strictly non-overlapping units:
| Ceiling | Unit | Enforced by | Configured by |
|---|---|---|---|
| `connection.maxConcurrentDownloads` | tasks | DAEMON | user |
| `Queue.maxConcurrent` | tasks | DAEMON | user |
| per-host **task** cap | tasks | DAEMON | host table |
| schedules, windows, queue order, priority | tasks | DAEMON | user |
| `connection.maxActiveSegments` | segments | **CORE** | user |
| `connection.maxSegmentsPerDownload` | segments | **CORE** | user, per task |
| per-host **segment** cap | segments | **CORE** | host table, pushed by DAEMON |
| `bufferBytes` / `maxTotalBufferBytes` | bytes | **CORE** | user |
| speed limits (global → queue → task) | bytes/s | **CORE** | user, pushed by DAEMON |
Corollaries, and these are the parts that actually prevent the two failure modes:
* **DAEMON never counts segments to make an admission decision.** Not directly, and not by
inferring occupancy from a download count. Its governor sees tasks.
* **CORE never refuses admission.** `start()` always accepts. The engine paces the task
inside the segment budget; it does not decide that the task should not be running. A
refusal would be a policy decision, and policy lives in the daemon with the queues and
the SQL behind it.
* The pattern generalises: **DAEMON decides policy and configures; CORE enforces every
ceiling counted in engine-internal units.** Rate limiting (`docs/04` §6) already works
this way. Recording it here so it is not re-litigated per subsystem.
### 2. The one legitimate coupling — a clamp, not a second enforcement
DAEMON reads `maxActiveSegments` in exactly one place:
```
effective_max_running_tasks = min(connection.maxConcurrentDownloads,
connection.maxActiveSegments)
```
with the same clamp applied per queue against that queue's share. The purpose is narrow:
never admit more concurrently-running tasks than the segment budget can give one segment
each. It is expressed in tasks, it throttles nothing that CORE also throttles, and it is
the reason §3's fairness rule is satisfiable.
At the shipped defaults (`maxConcurrentDownloads` 5, `maxActiveSegments` 32) the clamp is
not binding. It binds when a user raises concurrency to 64 or lowers the segment budget.
### 3. CORE's fairness rule — what DAEMON is allowed to assume
CORE owns this. It is recorded here because DAEMON's governor is built on top of it.
1. **Min-1 before seconds.** No task receives a second segment slot while any admitted task
holds zero. A task's first slot always outranks another task's growth.
2. Beyond the first slot, remaining budget is distributed round-robin over running tasks in
the priority order DAEMON supplies, up to each task's effective per-task cap:
`min(spec.segments ?? maxSegmentsPerDownload, per-host segment cap, 1 if not resumable)`.
3. Slots are released on segment completion, pause, and failure. **A steal is
slot-neutral** — the stealing worker already holds the slot it re-ranges.
4. A paused task holds no slots.
5. Therefore **admission implies progress**: a task DAEMON starts gets at least one
transferring segment, subject only to the connect timeout and the per-host cap.
6. **Invariant:** `budget().tasks_starved == 0` in steady state. DAEMON asserts this. If it
observes a non-zero value persisting past 2 s it logs a governor-invariant warning and
surfaces it in `velox ls --json`; it does **not** compensate by throttling admission.
Compensating is how the two governors would silently grow back into one.
Consequence for the starvation question in the brief: one download **cannot** take the
whole budget away from the next, because rule 1 makes the next task's first slot outrank
the incumbent's second. A single download alone in the system does legitimately grow to 32
segments, and gives slots back as tasks arrive — growth is opportunistic, the first slot is
guaranteed.
### 4. Per-host caps are split by unit, from one table
Both briefs say "per-host caps" and they are not the same cap.
* CORE enforces per-host **segment** caps — it owns the connections and is the only place
segments are counted (`docs/04` §3, "clamped per-host by settings").
* DAEMON enforces a per-host **task** cap, set to that host's segment cap, so it can never
admit more tasks for one host than that host can be given one segment each. Without this,
rule 3.1 is unsatisfiable: four tasks on a host capped at 4 connections is fine, five is
a guaranteed starved task no fairness rule can fix.
* The table itself is DAEMON state (SQLite, `settings`), pushed into the engine via
`set_host_segment_cap()`. One source of truth, two enforcement points, different units.
### 5. Probes do not consume segment slots
A `download.probe` is a HEAD or a one-byte ranged GET. Charging it against the segment
budget would let a burst of probes starve transfers, and probes are on the latency path for
`capture.offer`'s 750 ms deadline. CORE bounds concurrent probes with its own small pool
(proposed: 4) outside the segment budget. `capture.offer` never blocks on a probe under any
circumstances — it answers `ignore` first and probes after.
### 6. Contract gap — `connection.maxActiveSegments` does not exist on the wire yet
`Settings.schema.json` has `maxSegmentsPerDownload`, `bufferBytes`,
`maxConcurrentDownloads`; it has neither `maxActiveSegments` nor `maxTotalBufferBytes`.
PROTO accepted the B2a follow-up bundle (`contracts/proto-answers-m1.md`) but it has not
landed, and `SettingKey` is a closed enum against `additionalProperties: false` — a daemon
**cannot** add the key locally without failing conformance.
Until PROTO lands it: DAEMON holds `maxActiveSegments` as a daemon-local value defaulted to
32, passes it to `set_max_active_segments()` at startup, and does not expose it through
`settings.get` / `settings.set`. Filed as request C4 in `daemon/docs/core-requests-m1.md`
(to PROTO, alongside CORE's existing ask).
## Alternatives considered
**DAEMON enforces both.** The governor would have to predict each task's effective segment
count to spend a segment budget in task units — but that count depends on the probe result,
the per-host cap, the resumability demotion and live steals, all engine-internal and all
changing continuously. Predicting it means either over-admitting (the gap) or leaving the
link idle. Rejected: it asks the daemon to model the engine.
**CORE enforces both.** The engine would take every task and decide which run. That drags
queues, schedules, priority, and "when queue completes" into `core/`, which the layering
rule forbids and which would need SQL to be correct. Rejected.
**A shared semaphore object handed to both lanes.** Superficially the "one counter" answer,
but it makes a mutable engine resource part of the daemon's API surface, inverts the
dependency direction, and is the first thing that will deadlock under pause-during-steal.
Rejected: one counter, one owner, read-only snapshots for everyone else.
## Consequences
* `daemon/src/sched/` may be written against a task-unit model only. A segment count
appearing in an admission decision is a review-blocking defect.
* DAEMON needs an occupancy read-out rather than an inference — the engine API requested in
`daemon/docs/core-requests-m1.md` §1 (`budget()`, `segments_active(TaskId)`, a coalesced
change callback). Without it, projecting `TaskSummary.segments` (which ADR 0010 pinned to
the *effective* count) is guesswork.
* CORE's fairness rule needs a test that DAEMON can point at: N tasks admitted, budget
smaller than N × their per-task caps, assert every task holds ≥ 1 segment.
* If CORE cannot honour min-1 for a reason not anticipated here, this ADR is wrong rather
than incomplete — say so before `sched/` exists, which is the entire point of settling it
now.
## Open, for CORE to confirm or amend
1. Min-1 before seconds (§3.1) — is it implementable inside the stealer without a
priority inversion at slot release?
2. Probe pool outside the segment budget, size 4 (§5).
3. Does `set_max_active_segments()` apply live, draining as slots free rather than killing
in-flight segments?
4. `Priority` shape: an integer, or DAEMON handing over an ordered task list per tick?
5. Coalescing rate for the budget-change callback — 4 Hz to match the event-batching rate?