daemon: mark ADR 0013 fully accepted — PROTO landed the error-on-paused widening

PROTO closed the one remaining contract gap as contracts/ 1.3.0
(lane/proto commit 6db304a): event.task.state.error / TaskSummary.error
now populate on a paused transition CORE entered unilaterally, not just
on failed/retry_wait. Minor widening of an existing field's presence
condition, no retype, no new field, per contracts/README.md rule 4.

Updates every place in the ADR that referred to this as an open
question or unresolved gap: the status line, the pause-reason
bookkeeping in §2, the alternatives-considered pointer, and the
contract-gap section itself (renamed "surfaced, now closed"). Notes
1.3.0 is on lane/proto but not yet merged to main (still 1.1.0) —
daemon/src/sched/'s pause/resume logic should be written once that
merge lands, not before.

All four of ADR 0013's open items are now resolved: CORE confirmed
tasks_starved's structural exclusion and pause()/resume() idempotency
explicitly (verdict: "accept as written", not hedged), and adopted
"auto-pause" with no new wire term.

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:07:28 +04:00
co-authored by Claude Sonnet 5
parent 62cda074c1
commit 27e9ce9fb5
+34 -25
View File
@@ -1,13 +1,15 @@
# ADR 0013 — Task state-machine ownership: CORE, DAEMON, and the shared `paused` # 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 **Status:** accepted by CORE and PROTO; all four open items resolved · **Date:** 2026-09-09
· **Lane:** DAEMON, drafted at PROTO's request (D1 in `core/docs/proto-requests-m1.md`). · **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`) **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 — accepted as written, no amendments, plus the pinned `tasks_starved` definition and
`pause()`/`resume()` idempotency contract folded in below. `pause()`/`resume()` idempotency contract folded in below.
**Remaining blocker:** PROTO landing the `error`-on-`paused` widening (open item 3) — **PROTO's response:** the `error`-on-`paused` widening landed as `contracts/` **1.3.0**,
`daemon/src/sched/`'s pause/resume logic is unblocked from CORE's side already, but not `lane/proto` commit `6db304a` — no retype, no new field; only the presence condition on
buildable correctness-preservingly until that lands. the existing `error: TaskError | null` field widens to include a daemon-initiated pause.
Not yet merged to `main` (main is at 1.1.0 as of this writing) — `daemon/src/sched/`'s
pause/resume logic should be written against `1.3.0` once that merge lands, not before.
## Context ## Context
@@ -95,10 +97,10 @@ The contract that makes this safe, per CORE's sign-off (`core/docs/adr-0013-core
- **DAEMON persists *why* a task is paused**, in the `tasks` table, not in `TaskState` - **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 itself (the wire type stays a flat enum — this is DAEMON-local bookkeeping, not a
contract change). A `pauseReason` distinguishing at least `user`, `schedule`, contract change). A `pauseReason` distinguishing at least `user`, `schedule`,
`queue_stopped`, `admission_reconcile`, and CORE's `error.code` when auto-paused. This `queue_stopped`, `admission_reconcile`, and CORE's `error.code` when auto-paused
is what makes §3's resume rule possible — but it needs a contract fix first: see "A now readable off `event.task.state.error` since PROTO's 1.3.0 widening (see "A
contract gap this ADR surfaces" below. `error.code` is not currently carried on a contract gap this ADR surfaced, now closed" below). This is what makes §3's resume
transition into `paused` at all. rule possible.
- **CORE does not need to track why it's paused past the current occurrence.** Once - **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 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 resume, and DAEMON is also the only side with persistent storage to remember the
@@ -209,21 +211,25 @@ without CORE reporting them; forcing DAEMON to poll or reimplement that timing d
etc.).** Rejected — it roughly doubles the enum for a fact only DAEMON's resume logic 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 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 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 DAEMON needs (CORE's reason) travels on the existing `error` field instead — see the
contract gap in open item 3 — without touching `TaskState` itself. closed contract gap below — without touching `TaskState` itself.
## A contract gap this ADR surfaces, not just an open question ## A contract gap this ADR surfaced, now closed
`event.task.state.schema.json`'s own description scopes `error` to "whenever the new `event.task.state.schema.json`'s description scoped `error` to "whenever the new state is
state is failed or retry_wait" — **not** `paused`. The one fixture failed or retry_wait" — **not** `paused`, at the time this ADR was drafted, and the one
(`event.task.state.json`) only exercises the `failed` case. So today, when CORE fixture (`event.task.state.json`) only exercised the `failed` case. So CORE auto-pausing
auto-pauses for `auth_required` or `server_file_changed`, DAEMON has no signal on the wire for `auth_required` or `server_file_changed` had no wire signal telling DAEMON *why* — §2/
telling it *why* — §2/§3 of this ADR are unbuildable without one. This needs a PROTO §3 of this ADR were unbuildable without one.
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 **Closed by PROTO**, `contracts/` 1.3.0 (`lane/proto` commit `6db304a`, see the status
`state == "paused"` and the pause was CORE-initiated. DAEMON is not asking for a way to line at top for the merge caveat): minor widening of the existing field's presence
tell CORE-paused from user-paused on the wire in general — `error: null` on a condition, per `contracts/README.md` rule 4 — no new field, no retype, `error` stays
DAEMON-initiated pause is sufficient, since DAEMON already knows it just did that. `TaskError | null`. It is now populated on a `paused` transition whenever CORE entered it
unilaterally; `error: null` on a DAEMON-initiated pause is unchanged. New fixture
`event.task.state.auto-paused.json` exercises the auto-pause case; the existing
`event.task.state.json` fixture's stale "exactly when failed or retry_wait" assertion was
corrected in the same change.
## Resolution of the four open items ## Resolution of the four open items
@@ -236,10 +242,13 @@ DAEMON-initiated pause is sufficient, since DAEMON already knows it just did tha
task (pause racing completion isn't an error), and no state-change event fires for a 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 no-op — DAEMON reads resulting state from the callback/`TaskDetail`, never from the
call's return value. call's return value.
3. **Still open — PROTO.** CORE's half is ready (`ErrorInfo` is populated on every 3. **Landed — PROTO, `contracts/` 1.3.0** (`lane/proto` commit `6db304a`, not yet merged
auto-pause transition today's callback path would carry, using the existing B1 to `main`). `event.task.state.error` and `TaskSummary.error` are now populated "on
taxonomy); the wire only needs `error` permitted when `state == "paused"`. This is the every `failed` or `retry_wait` transition, and on a `paused` transition the daemon
one remaining blocker on `daemon/src/sched/`'s pause/resume logic. entered unilaterally" — a deliberate pause still carries `error: null`. New fixture
`event.task.state.auto-paused.json` exercises an `auth_required` auto-pause directly.
DAEMON's §3 resume rule can be implemented once `sched/` is built against `main` at
1.3.0 or later.
4. **CORE adopts "auto-pause".** No new wire or API term — the discriminator stays 4. **CORE adopts "auto-pause".** No new wire or API term — the discriminator stays
`state == paused` plus the presence of an `Error` (present ⇒ CORE-initiated, absent ⇒ `state == paused` plus the presence of an `Error` (present ⇒ CORE-initiated, absent ⇒
DAEMON-initiated), exactly as §2 already specified. DAEMON-initiated), exactly as §2 already specified.