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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user