daemon: accept ADR 0011 with CORE's sign-off; land the engine API

CORE reviewed and accepted (core/docs/adr-0011-core-response.md,
lane/core@7bf5cb5), with three amendments folded in:

- yield (slot transfer at the next segment boundary) as the mechanism
  that satisfies min-1-before-seconds out of a full budget; steal
  stays slot-neutral as originally written.
- "admission implies progress" is bounded-delay
  (min(next yield boundary, low_speed_secs) + connect_timeout), not
  immediate — widens the starvation-assertion window from 2s to
  ~low_speed_secs + connect_timeout (45s).
- starved_tasks()/starved_since(TaskId) added to the accessor set;
  segments_active() and tasks_starved definitions pinned (a
  'connecting' segment counts as held, not starved).

All five open questions answered (min-1 buildable without inversion,
probe pool size 4 outside the budget, drain-not-kill live-apply,
ordered TaskId list for priority, 4Hz + starved-edge callback
coalescing). Section 6 rewritten: connection.maxActiveSegments landed
on the wire in PROTO's ADR 0012 while this was in flight, so the
daemon-local stopgap is dropped.

daemon/src/sched/ is unblocked. Both docs updated in the rebased
vdm-daemon worktree against the frozen 1.0.0 contract.

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:24:39 +04:00
co-authored by Claude Sonnet 5
parent ecedfac903
commit 67b7b75336
2 changed files with 170 additions and 116 deletions
+47 -58
View File
@@ -1,78 +1,67 @@
# 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.
Status: **resolved**. CORE answered in full: `core/docs/adr-0011-core-response.md`
(`lane/core`, commit `7bf5cb5`). Kept as a record of what was asked and the shape of the
answer; the API itself is now specified in
`docs/adr/0011-admission-control-and-the-segment-budget.md` §"Engine API `sched/` is built
against". `sched/` may be written against it.
Ranking follows `contracts/README.md` rule 4 conventions even though this isn't a
Ranking followed `contracts/README.md` rule 4 conventions even though this wasn'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.
fairness) = needed CORE's explicit sign-off before DAEMON built on the assumption. That
sign-off is in.
---
## C1. Occupancy read-out, not inference
## C1. Occupancy read-out, not inference — **resolved**
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:
Requested `budget()`, `segments_active(TaskId)`, `on_budget_changed`. CORE's answer adds
`starved_tasks()` and `starved_since(TaskId)` (amendment A3) and pins two definitions:
`segments_active(id)` counts a segment in `connecting` state as held (it is progress, not
starvation), and `tasks_starved` counts only `segments_active == 0`. See ADR 0011 §3.6.
- `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)` live-apply — **resolved: drain, never kill**
## C2. `set_max_active_segments(uint32_t)` and its live-apply semantics
Confirmed DAEMON's assumption. Lowering runs in-flight segments to their next boundary; no
new segment starts while over the new ceiling; nothing is aborted, no partial range lost.
If the new ceiling is below the running-task count, CORE honours min-1 for the top-priority
subset and reports the rest via `tasks_starved` — DAEMON's governor must reconcile and
pause the lowest-priority excess itself (CORE does not auto-pause). See ADR 0011 §2.
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)` — **resolved, confirmed as proposed**
## C3. `set_host_segment_cap(host, uint32_t)`
CORE keeps the `host → cap` map and derives a task's host from its URL/mirror set; DAEMON
owns the table and pushes it. See ADR 0011 §4.
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` — **resolved by PROTO**
## C4. Contract gap: `connection.maxActiveSegments` isn't in `Settings.schema.json`
PROTO landed it (ADR 0012, `connection.maxActiveSegments` default 32,
`connection.maxTotalBufferBytes` default 128 MiB, `TaskDetail.effectiveBufferBytes`) while
this was in flight. No daemon-local stopgap needed — `sched/` reads the wire field
directly. See ADR 0011 §6.
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 — **resolved, with two amendments**
## C5. Fairness rule sign-off (ADR 0011 §3)
CORE confirmed min-1-before-seconds is implementable without a priority-inversion at slot
release (two-pass allocator: guarantee pass over zero-slot tasks in DAEMON's priority
order, then a growth pass; a released slot always re-enters the pool at pass 1, never
handed back locally). Two amendments to what DAEMON assumed:
Not new API — a behavioural contract. DAEMON's governor is built assuming:
- **A1** — "steal" (slot-neutral, unchanged) isn't the whole mechanism; **"yield"** is the
slot-transfer operation that actually satisfies min-1 out of a full budget: an
over-quota task releases one slot at its next segment boundary, bounded by that
segment's remaining bytes.
- **A2** — "admission implies progress" is **bounded-delay**, not immediate:
`time_to_first_slot ≤ min(next yield boundary, low_speed_secs) + connect_timeout`, not
"connect timeout + per-host cap" alone. DAEMON's starvation-invariant assertion window
widened from the originally proposed 2 s to `low_speed_secs + connect_timeout` (~45 s)
accordingly.
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).
Priority order (open item 4) is an ordered `TaskId` list pushed via `set_task_order` on
change — not an integer, not per-tick. See ADR 0011 §3.
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 — **resolved, confirmed**
## 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.
Dedicated pool, default size 4, `set_probe_pool_size(uint32_t)`, independent of
`maxActiveSegments`; probe cancellation is immediate. DAEMON still bounds probe
*submission* on its own side. See ADR 0011 §5.