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
13 KiB
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 loweredmaxActiveSegments(ADR 0011 §2 — pausing the lowest-priority excess). DAEMON calls CORE'spause(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 topausedon 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 callingpause()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 topausedwhen DAEMON's pause request arrives, the result is stillpaused, once.- DAEMON persists why a task is paused, in the
taskstable, not inTaskStateitself (the wire type stays a flat enum — this is DAEMON-local bookkeeping, not a contract change). ApauseReasondistinguishing at leastuser,schedule,queue_stopped,admission_reconcile, and CORE'serror.codewhen 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.codeis not currently carried on a transition intopausedat 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/callsstart(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
taskstable needs apauseReasoncolumn (DAEMON-local; not a wire type) beforesched/'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 excluderetry_waitand 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, andpreviousStateare 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
- Does CORE's
starved_tasks()already excluderetry_waitand auto-paused tasks, or does this ADR ask for a behavior change there (§4)? - Is
pause()idempotent today, or does calling it on an already-paused task currently return an error CORE needs to relax? - PROTO: land the
error-on-pausedwidening 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. - 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?