daemon: draft ADR 0013 — task state-machine ownership (D1)

Drafts the CORE/DAEMON split PROTO raised as D1 and CORE already agreed
to in contracts/proto-answers-m1.md, since neither lane would write it
down alone. DAEMON owns new/queued and pause-for-schedule; CORE owns
probing through complete|failed and cancelled-from-anywhere is
DAEMON-driven; paused is shared.

Ties into ADR 0011 in two places:
- retry_wait looks identical to segment starvation from the budget
  accessor's point of view (zero segments, deliberately) and must be
  excluded from tasks_starved by construction, not by DAEMON guessing
  from timing.
- restart handling: CORE holds no persistent state, so any CORE-owned
  TaskState reloads as queued and re-admits through the scheduler;
  paused tasks reload with their pauseReason intact.

Surfaces one real contract gap while drafting, not just an open
question: event.task.state's error field is schema-scoped to
failed/retry_wait only, so CORE auto-pausing for auth_required or
server_file_changed currently has no wire signal telling DAEMON why —
needed before the resume-must-not-cross-reasons rule (§3) can be
implemented at all. Filed as a PROTO follow-up in the ADR itself.

Status: proposed, needs CORE + PROTO sign-off (four open items at the
end) before daemon/src/sched/'s pause/resume logic is written against
it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
This commit is contained in:
2026-09-10 00:00:08 +04:00
co-authored by Claude Sonnet 5
parent fdacf732fa
commit 19d3cd2b1d
@@ -0,0 +1,204 @@
# ADR 0013 — Task state-machine ownership: CORE, DAEMON, and the shared `paused`
**Status:** proposed · **Date:** 2026-09-09 · **Lane:** DAEMON, drafted at PROTO's request
(D1 in `core/docs/proto-requests-m1.md`), for CORE and PROTO to review and sign off.
**Needs:** explicit accept from CORE and PROTO before `daemon/src/sched/` calls into CORE's
task-start API, since the calling convention below is derived from this split.
## Context
`docs/04` §1 hands CORE the download lifecycle (`probing → connecting → downloading ⇄
paused → retry_wait → assembling → verifying → complete | failed`), but `queued` and every
scheduler transition are DAEMON's, and neither brief says where the seam is. PROTO raised
this as D1 and proposed a split; CORE agreed (`contracts/proto-answers-m1.md` D1); both
declined to write it down alone — a two-lane decision recorded by the lane that owns
neither half is exactly the failure mode `contracts/README.md` rule 4 exists to prevent.
DAEMON drafts it because DAEMON is the one lane touching both halves (it owns the SQL row
and the RPC surface; it calls into CORE).
The wire side is already frozen and does not need to change: `TaskState`
(`contracts/schema/types/TaskState.schema.json`) has the twelve values with no field
naming who drove a transition, and `event.task.state` carries `previousState` — a client
renders what it's told and keeps no state machine of its own. This ADR is about who
*decides* a transition, not the wire shape of one.
This is also where ADR 0011 (admission control) and the state machine meet: `queued →
connecting` is the admission event, and `retry_wait` looks identical to starvation from
inside the segment-budget accounting. Both are addressed below because getting them wrong
independently produces the same false alarm from two different directions.
## Decision
### 1. Ownership, by state
| State | Driven by | Entered from | Notes |
|---|---|---|---|
| `new` | DAEMON | (creation) | `download.add` persists the row before CORE knows the task exists. |
| `queued` | DAEMON | `new`, `paused` (resume, subject to §3) | Scheduler admission pool. CORE has no concept of "queued" — `retry_wait` re-enters `connecting` directly inside CORE and never passes back through `queued`. |
| `probing` | CORE | `queued` | DAEMON calls CORE's `start()` once admitted (ADR 0011 §1); CORE owns everything from here until it hands back a terminal or `paused` state. |
| `connecting` | CORE | `probing`, `retry_wait` | |
| `downloading` | CORE | `connecting` | |
| `paused` | **shared** | any CORE state, `queued` | See §2 — this is the one state either side may enter unilaterally. |
| `retry_wait` | CORE | `connecting`, `downloading` | CORE's own backoff timer (`docs/04` §7); DAEMON does not schedule retries. |
| `assembling` | CORE | `downloading` | Normally a no-op rename; a real state for HLS/DASH muxing. |
| `verifying` | CORE | `assembling` | Checksum verification when requested. |
| `complete` | CORE | `verifying` | Terminal. |
| `failed` | CORE | any CORE state | Terminal. `max_retries_exhausted` after `retry_wait`, or any non-retryable error. |
| `cancelled` | DAEMON | any state | Terminal. Always user- or policy-initiated (`download.cancel`, category/queue removal) — CORE never cancels on its own; it only executes the teardown DAEMON asked for and confirms. |
Reading the table as a picture:
```
DAEMON: new ──▶ queued ──▶[admit]──▶ (hand to CORE)
▲ │
│ resume CORE: probing ─▶ connecting ─▶ downloading
pause────┤ │ │ ▲ │ │
(either side)│ retry_wait◀────────┘ │ │ │
▼ │ (backoff done)│ ▼ ▼
paused ◀──────────(auto-pause)─────┘ assembling paused
│ (auto)
verifying
complete
(from anywhere, DAEMON-driven) ──────────────────────▶ cancelled
(from any CORE state, CORE-driven, non-retryable) ───▶ failed
```
### 2. `paused` is shared, and idempotency is the whole contract
Both sides can put a task in `paused`, for disjoint reasons:
- **DAEMON-initiated**: user clicks pause, a schedule window closes, a queue is stopped,
`Queue.onComplete`, or the admission governor reconciling a lowered `maxActiveSegments`
(ADR 0011 §2 — pausing the lowest-priority excess). DAEMON calls CORE's `pause(TaskId)`.
- **CORE-initiated ("auto-pause")**: `auth_required` (401/407, `docs/04` §7),
`server_file_changed` (F1's carrier, `contracts/proto-answers-m1.md`), disk full. CORE
transitions to `paused` on its own and reports it up through the existing
state-change callback — the same path every other CORE-driven transition uses. This is
not a new mechanism.
The contract that makes this safe:
- **`pause()` is idempotent.** DAEMON calling `pause()` on a task CORE already
auto-paused is a no-op, not an error — DAEMON does not need to know CORE got there
first. Symmetrically, if CORE were ever mid-transition to `paused` when DAEMON's pause
request arrives, the result is still `paused`, once.
- **DAEMON persists *why* a task is paused**, in the `tasks` table, not in `TaskState`
itself (the wire type stays a flat enum — this is DAEMON-local bookkeeping, not a
contract change). A `pauseReason` distinguishing at least `user`, `schedule`,
`queue_stopped`, `admission_reconcile`, and CORE's `error.code` when auto-paused. This
is what makes §3's resume rule possible — but it needs a contract fix first: see the
gap in open item 3 below. `error.code` is not currently carried on a transition into
`paused` at all.
- **CORE does not need to track why it's paused past the current occurrence.** Once
paused, CORE's job is done; DAEMON is the only side that later decides whether to
resume, and DAEMON is also the only side with persistent storage to remember the
reason across a restart.
### 3. Resume must not cross reasons
The bug this section exists to prevent: a schedule window opens, DAEMON blindly resumes
every `paused` task in that queue, and it resumes a task CORE paused because it's waiting
on credentials that were never provided. The task immediately re-fails or re-pauses, looks
like a flapping bug, and burns a retry.
Rule: **DAEMON resumes a task only when the reason it recorded matches the event that
justifies resuming.** A schedule window opening resumes tasks paused for `schedule`. A
user clicking "resume" resumes anything (explicit user intent overrides any reason).
`auth_required` and `server_file_changed` are resumed only by the paths that actually
address them — `download.provideAuth` (F2) and the user's restart-decision response (F1) —
never by the scheduler. This means `queued` is not the only state a schedule can put a
task back into a run cycle from; the scheduler's "should this task be running right now"
check must skip tasks paused for a reason it doesn't own.
### 4. `retry_wait` is not starvation
ADR 0011 §3.6 defines a starved task as a running task with `segments_active(id) == 0`,
and asserts `tasks_starved == 0` in steady state past a bounded delay. A task in
`retry_wait` also holds zero segments, deliberately, for up to 60 s (`docs/04` §7's backoff
cap) — and it is **not** admission-starved, it is CORE's own policy holding it idle.
Resolution: `retry_wait` must be distinguishable from true starvation without DAEMON
guessing from timing. CORE's `starved_tasks()` (ADR 0011) excludes any task CORE itself
holds at zero by policy — `retry_wait` and CORE-initiated `paused` both fall outside
`tasks_starved` by construction, because that accessor is about the segment allocator
failing to grant a slot to a task that wants one, not about a task that isn't asking. A
task in `retry_wait` still counts against `connection.maxConcurrentDownloads` from
DAEMON's side (it is running, not requeued) but contributes nothing to the segment
allocator's guarantee pass until it re-enters `connecting`.
### 5. Restart — CORE holds no persistent state, DAEMON reloads to `queued`
Per the M1 DoD ("kill and restart the daemon mid-download: all tasks reload with correct
state and resume") and the layering rule (CORE has no SQL, no state survives a CORE
restart except what's on disk in `.veloxpart.meta`, which CORE reads back itself): on
daemon startup, DAEMON loads every non-terminal task from SQLite. Any task whose persisted
`TaskState` was a CORE-owned state (`probing` through `verifying`) is **rewritten to
`queued`** in memory before the scheduler sees it — the on-disk `TaskState` is a
last-known-value, not a resumable position, because CORE's in-process state died with the
process. The scheduler re-admits it exactly like any other queued task; CORE re-derives
where to actually resume from `.veloxpart.meta` and re-validates with `If-Range`
(`docs/04` §5), independent of what SQL said the state was.
`paused` tasks reload as `paused`, with their `pauseReason` intact, and are not
auto-admitted — §3 applies identically after a restart as it does live.
## Consequences
- `daemon/src/sched/` calls `start(TaskId)` exactly once per admission (`queued →
probing`), never re-enters a CORE-owned state directly, and treats every CORE-owned
state as opaque past that call except for reading it back for projection.
- The `tasks` table needs a `pauseReason` column (DAEMON-local; not a wire type) before
`sched/`'s pause/resume logic can be written correctly — flagging as a concrete
follow-up, not blocking this ADR's acceptance.
- CORE's `starved_tasks()` (ADR 0011) must exclude `retry_wait` and CORE-auto-paused tasks
by construction; if that isn't already true in CORE's accessor, it needs to be before
DAEMON relies on the starvation invariant, since otherwise every backoff cycle would
trip DAEMON's governor-invariant warning as a false positive.
- No `contracts/` change. `TaskState`, `event.task.state`, and `previousState` are already
sufficient; this ADR is entirely about which process calls which function when.
## Alternatives considered
**A single owner drives every transition (CORE, told about queues).** Rejected — this is
the D1 problem restated with CORE holding the SQL-shaped concepts (`queued`, schedules,
priority) that the layering rule (`CLAUDE.md` §3) forbids it from touching.
**DAEMON drives every transition, treating CORE as a dumb byte-mover.** Rejected — CORE's
internal states (`retry_wait`, `assembling`, `verifying`) depend on engine internals
(backoff timers, mux completion, streaming hash state) DAEMON has no visibility into
without CORE reporting them; forcing DAEMON to poll or reimplement that timing duplicates
`docs/04` §7 in two places and they will drift.
**A `pauseReason` on the wire (`TaskState` split into `paused_user` / `paused_auto` /
etc.).** Rejected — it roughly doubles the enum for a fact only DAEMON's resume logic
needs, and adding wire cardinality for internal bookkeeping is the kind of thing that
becomes a compatibility problem the moment a client starts branching on it. The value
DAEMON needs (CORE's reason) can travel on the existing `error` field instead — see the
contract gap in open item 3 — without touching `TaskState` itself.
## A contract gap this ADR surfaces, not just an open question
`event.task.state.schema.json`'s own description scopes `error` to "whenever the new
state is failed or retry_wait" — **not** `paused`. The one fixture
(`event.task.state.json`) only exercises the `failed` case. So today, when CORE
auto-pauses for `auth_required` or `server_file_changed`, DAEMON has no signal on the wire
telling it *why* — §2/§3 of this ADR are unbuildable without one. This needs a PROTO
follow-up (minor: widening an existing field's presence condition, per
`contracts/README.md` rule 4 — no new field, no retype) to also populate `error` when
`state == "paused"` and the pause was CORE-initiated. DAEMON is not asking for a way to
tell CORE-paused from user-paused on the wire in general — `error: null` on a
DAEMON-initiated pause is sufficient, since DAEMON already knows it just did that.
## Open, for CORE and PROTO to confirm or amend
1. Does CORE's `starved_tasks()` already exclude `retry_wait` and auto-paused tasks, or
does this ADR ask for a behavior change there (§4)?
2. Is `pause()` idempotent today, or does calling it on an already-paused task currently
return an error CORE needs to relax?
3. PROTO: land the `error`-on-`paused` widening above before DAEMON writes the resume
logic in §3 — otherwise DAEMON has no correctness-preserving way to implement it and
would have to guess from timing, which is the exact failure mode ADR 0011 was written
to rule out for a different pair of governors.
4. Naming: is "auto-pause" the term CORE already uses internally, or does CORE have an
existing name for this that this ADR should adopt instead of introducing a new one?