Files
vdm/docs/adr/0013-task-state-machine-ownership.md
T
samiandClaude Sonnet 5 19d3cd2b1d 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
2026-09-10 00:00:08 +04:00

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 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?