CORE accepted ADR 0013 as written, no amendments (core/docs/adr-0013-core-response.md, lane/core@c65e664). Folds in: - pause()/resume() idempotency contract, precisely: no-op success on an already-paused task, ALSO on a terminal task (pause racing completion isn't an error), resume() no-op on a non-paused task, the only error is task_not_found, and no state-change event fires for a no-op call. - tasks_starved pinned as {connecting, downloading} AND segments_active == 0 — a structural exclusion of retry_wait and auto-paused tasks rather than a special case, with the full state table CORE gave. - restart handling confirmed fully; two non-blocking notes from CORE about work-interruption during verifying/assembling. - "auto-pause" adopted as the term, no new wire/API surface. Status updated: accepted by CORE; PROTO's item 3 (permit `error` on event.task.state when state=="paused") is the one remaining blocker before daemon/src/sched/'s pause/resume logic can be written correctness-preservingly. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
16 KiB
ADR 0013 — Task state-machine ownership: CORE, DAEMON, and the shared paused
Status: accepted by CORE, PROTO's open item 3 outstanding · Date: 2026-09-09
· Lane: DAEMON, drafted at PROTO's request (D1 in core/docs/proto-requests-m1.md).
CORE's response: core/docs/adr-0013-core-response.md (lane/core, commit c65e664)
— accepted as written, no amendments, plus the pinned tasks_starved definition and
pause()/resume() idempotency contract folded in below.
Remaining blocker: PROTO landing the error-on-paused widening (open item 3) —
daemon/src/sched/'s pause/resume logic is unblocked from CORE's side already, but not
buildable correctness-preservingly until that lands.
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, per CORE's sign-off (core/docs/adr-0013-core-response.md):
pause(TaskId)is idempotent: a no-op success on a task alreadypaused(however it got there), and also a no-op success on a terminal task (complete/failed/cancelled) — a pause racing a completion is not an error.resume(TaskId)is a no-op success on a task that is not paused. The only error either returns istask_not_found. CORE emits no state-change event for a no-op call — DAEMON reads the resulting state from the normal state-change callback /TaskDetail, never frompause()'s orresume()'s return value. If CORE is mid-transition intopaused(its own auto-pause) when DAEMON'spause()arrives, the task ends uppausedonce, with exactly one state-change event.- 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 "A contract gap this ADR surfaces" 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.
Pinned by CORE's sign-off, tightening ADR 0011's definition rather than special-casing
it: tasks_starved counts only tasks whose TaskState is connecting or downloading
and segments_active == 0 — precisely "the allocator has not granted a slot to a task
that is asking for one."
| Task state | In tasks_starved? |
Why |
|---|---|---|
probing |
no | uses the probe pool (ADR 0011 §5), not the segment budget |
connecting, 0 segments |
yes | admitted + probed, waiting on the allocator's first grant — the real starvation case |
connecting/downloading, ≥1 segment |
no | a segment in its own connecting sub-state counts as held (ADR 0011 amendment A3) |
downloading, 0 segments |
yes | held slots and lost them all (e.g. every segment failed and is being re-requested) — transient, still real |
retry_wait |
no | CORE's backoff timer holds it at zero deliberately; not asking the allocator for anything until it re-enters connecting |
paused (either-initiated) |
no | not asking for a slot |
new, queued, assembling, verifying, terminal |
no | outside the counted state set |
So retry_wait and auto-paused tasks are excluded structurally, by not being in the
{connecting, downloading} state set — not via a special case that could rot as the
engine evolves. starved_tasks() returns exactly the TaskIds in this count;
starved_since(id) is defined only for them. 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.
Confirmed by CORE, fully. start(TaskId) transparently checks for a valid
.veloxpart.meta sidecar (CORE's stage 5), re-validates with If-Range (docs/04 §5),
and either resumes from the recorded offsets or restarts if the sidecar is missing or
fails validation. DAEMON does nothing special beyond rewriting CORE-owned states to
queued and re-admitting, as this section already said.
Two notes from CORE, not objections to the ADR: the transition into paused is
honoured from any CORE state per §1's table, but the work interruption is best-effort —
a pause during verifying discards the in-progress hash and re-hashes from the start on
resume (cheap, bounded); a pause during assembling (HLS/DASH mux) is an M4 concern and
may not be cleanly interruptible mid-mux. Neither changes this ADR's API shape.
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) excludesretry_waitand CORE-auto-paused tasks by construction — pinned in CORE's sign-off as the{connecting, downloading} ∧ segments_active == 0definition in §4's table — so DAEMON's governor-invariant warning will not false-positive on a normal backoff cycle. - 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.
Resolution of the four open items
starved_tasks()excludesretry_wait/auto-paused by construction — confirmed. Pinned as a design commitment in CORE's sign-off (§4's table above): the counted set is{connecting, downloading} ∧ segments_active == 0, so exclusion is structural, not a special case.pause()/resume()idempotency — confirmed as specified in §2 above, plus two details this ADR hadn't anticipated: a no-op call also succeeds against a terminal task (pause racing completion isn't an error), and no state-change event fires for a no-op — DAEMON reads resulting state from the callback/TaskDetail, never from the call's return value.- Still open — PROTO. CORE's half is ready (
ErrorInfois populated on every auto-pause transition today's callback path would carry, using the existing B1 taxonomy); the wire only needserrorpermitted whenstate == "paused". This is the one remaining blocker ondaemon/src/sched/'s pause/resume logic. - CORE adopts "auto-pause". No new wire or API term — the discriminator stays
state == pausedplus the presence of anError(present ⇒ CORE-initiated, absent ⇒ DAEMON-initiated), exactly as §2 already specified.