diff --git a/docs/adr/0013-task-state-machine-ownership.md b/docs/adr/0013-task-state-machine-ownership.md index 612e08b..eefbd99 100644 --- a/docs/adr/0013-task-state-machine-ownership.md +++ b/docs/adr/0013-task-state-machine-ownership.md @@ -1,13 +1,15 @@ # 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`). **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. +**PROTO's response:** the `error`-on-`paused` widening landed as `contracts/` **1.3.0**, +`lane/proto` commit `6db304a` — no retype, no new field; only the presence condition on +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 @@ -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` 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 "A - contract gap this ADR surfaces" below. `error.code` is not currently carried on a - transition into `paused` at all. + `queue_stopped`, `admission_reconcile`, and CORE's `error.code` when auto-paused — + now readable off `event.task.state.error` since PROTO's 1.3.0 widening (see "A + contract gap this ADR surfaced, now closed" below). This is what makes §3's resume + rule possible. - **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 @@ -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 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. +DAEMON needs (CORE's reason) travels on the existing `error` field instead — see the +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 -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. +`event.task.state.schema.json`'s description scoped `error` to "whenever the new state is +failed or retry_wait" — **not** `paused`, at the time this ADR was drafted, and the one +fixture (`event.task.state.json`) only exercised the `failed` case. So CORE auto-pausing +for `auth_required` or `server_file_changed` had no wire signal telling DAEMON *why* — §2/ +§3 of this ADR were unbuildable without one. + +**Closed by PROTO**, `contracts/` 1.3.0 (`lane/proto` commit `6db304a`, see the status +line at top for the merge caveat): minor widening of the existing field's presence +condition, per `contracts/README.md` rule 4 — no new field, no retype, `error` stays +`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 @@ -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 no-op — DAEMON reads resulting state from the callback/`TaskDetail`, never from the call's return value. -3. **Still open — PROTO.** CORE's half is ready (`ErrorInfo` is populated on every - auto-pause transition today's callback path would carry, using the existing B1 - taxonomy); the wire only needs `error` permitted when `state == "paused"`. This is the - one remaining blocker on `daemon/src/sched/`'s pause/resume logic. +3. **Landed — PROTO, `contracts/` 1.3.0** (`lane/proto` commit `6db304a`, not yet merged + to `main`). `event.task.state.error` and `TaskSummary.error` are now populated "on + every `failed` or `retry_wait` transition, and on a `paused` transition the daemon + 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 `state == paused` plus the presence of an `Error` (present ⇒ CORE-initiated, absent ⇒ DAEMON-initiated), exactly as §2 already specified.