Машина состояний: декларативный граф легальных переходов
Единый источник истины `allowedTransitions` (from → {разрешённые to}) в
internal/store; `setState` сверяет переход дополнительным SQL-предикатом
`state IN (<легальные источники>)` — необъявленное ребро (и не самопереход)
отклоняется атомарно, с точным сообщением. Гейт ортогонален гарду
терминальности: ребро из терминального состояния проходит только через
ActivateIfNoOtherActive. Без внешней библиотеки-FSM (обоснование — design.md).
Граф выведен построчно из воркера; ревью дизайна поймало 8 preflight-рёбер
(reconcileToReality → orphaned/deleted) и linking→cancel/defer после краха.
Тест-инвариант «cancel/defer достижимы из любого не-терминального» ловит класс
пропущенного ребра. Фикстуры тестов, форсившие состояния через SetDownloadState,
переведены на прямой UPDATE (forceState).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-08
|
||||
@@ -0,0 +1,144 @@
|
||||
# Design: декларативный граф переходов
|
||||
|
||||
## Почему не библиотека (looplab/fsm, qmuntal/stateless)
|
||||
|
||||
Отклонено сознательно, три причины из нашего кода:
|
||||
|
||||
1. **Тяжёлую часть библиотека не заберёт — она в SQL.** Настоящий инвариант («не
|
||||
более одной активной загрузки на infohash» + «терминальную нельзя молча оживить»)
|
||||
держится атомарно в SQLite-транзакциях (`BEGIN IMMEDIATE` через `_txlock`):
|
||||
`CreateDownloadIfNoActive`, `ActivateIfNoOtherActive`, гард `WHERE state='catched'`
|
||||
в `PromoteCatched`. In-memory FSM физически не может участвовать в транзакции с БД —
|
||||
она сядет поверх реального гарда как второй, более слабый слой.
|
||||
|
||||
2. **У нас не событийный автомат, а reconciliation.** Воркер в основном *сверяет*
|
||||
состояние БД с реальностью qBittorrent (`reconcile`, `reconcileDesync` — двумерная
|
||||
матрица источник×цель, `reconcileRecovery`). FSM-библиотеки моделируют линейный
|
||||
«событие → переход» хорошо, а сверку — плохо.
|
||||
|
||||
3. **Состояние persisted, а не в объекте.** State — колонка SQLite, перечитывается
|
||||
каждый тик. Библиотечная FSM держит состояние в структуре; пришлось бы
|
||||
конструировать FSM-объект на загрузку на тик только ради валидации одного ребра.
|
||||
|
||||
Соразмерная альтернатива — свой декларативный граф в `internal/store`: 80% ценности
|
||||
(граф в одном месте, документирован, тестируем, роняет нелегальный переход) без
|
||||
зависимости, без второго слоя, без импеданса. Соответствует принципу «минимум
|
||||
компонентов».
|
||||
|
||||
## Где живёт гейт: `store.setState`
|
||||
|
||||
`setState` — истинная точка схождения: через неё проходят и `SetDownloadState`
|
||||
(`reviveOK=false`), и `ActivateIfNoOtherActive` (`reviveOK=true`). Единственный
|
||||
обход — `PromoteCatched` (собственный `UPDATE ... WHERE state='catched'`): его переход
|
||||
`catched → downloading` объявлен ребром графа, а from-состояние жёстко фиксирует его
|
||||
собственный гард, поэтому он согласован по построению и в `setState` не заводится.
|
||||
|
||||
Гейт — **SQL-предикатом**, не Go-проверкой с предварительным чтением:
|
||||
|
||||
```
|
||||
UPDATE download SET state=?, ... WHERE id=?
|
||||
AND state IN (<легальные источники для to>) -- граф (+ сам to: самопереход)
|
||||
AND state NOT IN (<terminal>) -- прежний гард, только если !reviveOK и to не терминально
|
||||
```
|
||||
|
||||
Так проверка остаётся атомарной (без окна между чтением и записью), естественно
|
||||
композится с существующими предикатами и не требует держать доп. блокировку. При
|
||||
0 строк — одно диагностическое чтение текущего состояния (только на пути ошибки,
|
||||
редко) даёт точное сообщение: «not found» / «illegal transition from→to».
|
||||
|
||||
### Ортогональность (ключевой инвариант дизайна)
|
||||
|
||||
Граф и гард терминальности **независимы и оба применяются**:
|
||||
|
||||
- Граф говорит «ребро `failed → downloading` существует» (его выполняет retry).
|
||||
- Гард терминальности говорит «но обычным `SetDownloadState` терминальную не оживить».
|
||||
|
||||
Поэтому `failed → downloading` проходит только через `ActivateIfNoOtherActive`
|
||||
(`reviveOK=true`, гард терминальности снят, но проверка владения хешами добавлена), а
|
||||
`SetDownloadState(failed → downloading)` отклоняется. Гейт графа **аддитивен**: он
|
||||
ничего не ослабляет, только добавляет ещё одно необходимое условие.
|
||||
|
||||
## Самопереходы
|
||||
|
||||
Правило: `from == to` разрешён всегда (в графе не перечисляется). Причина —
|
||||
идемпотентная переустановка того же состояния уже используется (напр. `Defer` на уже
|
||||
`deferred`-задаче; повторная запись `error_msg`). `legalSources(to)` всегда включает
|
||||
сам `to`. Это сохраняет текущую семантику (такой UPDATE успешен, трогает
|
||||
`error_*`/`updated_at`) и избавляет от ручного перечисления петель.
|
||||
|
||||
## Граф (from → to), выведенный из реального кода
|
||||
|
||||
Источник каждого ребра — конкретный метод воркера/store (`w.transition` →
|
||||
`SetDownloadState`, `ActivateIfNoOtherActive`, `PromoteCatched`, `reconcileToReality`):
|
||||
|
||||
| from | to | кто выполняет |
|
||||
|------|----|----|
|
||||
| `catched` | `downloading` | `PromoteCatched` (успех add) |
|
||||
| `catched` | `failed` | `processCatched` таймаут (`qbit_add`) |
|
||||
| `catched` | `cancelled` | `Cancel` |
|
||||
| `catched` | `deferred` | `Defer` (любое не-терминальное) |
|
||||
| `downloading` | `completed` | `reconcile` (classReady) |
|
||||
| `downloading` | `failed` | `reconcile` (qbit_error), `checkTimeouts` (magnet_timeout) |
|
||||
| `downloading` | `stuck` | `checkTimeouts` (stalled) |
|
||||
| `downloading` | `cancelled` | `Cancel` |
|
||||
| `downloading` | `deferred` | `Defer` |
|
||||
| `completed` | `recognizing` | `recognizeOne` |
|
||||
| `completed` | `cancelled` / `deferred` | `Cancel` / `Defer` |
|
||||
| `recognizing` | `linking` | `finishRecognition` (авто) |
|
||||
| `recognizing` | `review` | `finishRecognition` |
|
||||
| `recognizing` | `cancelled` / `deferred` | `Cancel` / `Defer` (окно на время LLM) |
|
||||
| `review` | `linking` | `Apply` |
|
||||
| `review` | `recognizing` | `Refine` / `Rerecognize` / `SetType` |
|
||||
| `review` | `deferred` / `cancelled` | `Defer` / `Cancel` |
|
||||
| `review` | `orphaned` / `deleted` | `reconcileToReality` (preflight: источник пропал) |
|
||||
| `linking` | `done` | `linkPlan` (успех) |
|
||||
| `linking` | `review` | `linkPlan` (build/collision) |
|
||||
| `linking` | `failed` | `linkPlan` (apply error) |
|
||||
| `linking` | `cancelled` / `deferred` | `Cancel` / `Defer` (задача застряла в `linking` после краха процесса) |
|
||||
| `done` | `reverted` | `Undo` |
|
||||
| `done` | `target_missing` / `orphaned` / `deleted` | `reconcileDesync` |
|
||||
| `deferred` | `linking` | `Apply` |
|
||||
| `deferred` | `recognizing` | `Refine` / `Rerecognize` / `SetType` |
|
||||
| `deferred` | `cancelled` | `Cancel` |
|
||||
| `deferred` | `orphaned` / `deleted` | `reconcileToReality` (preflight: источник пропал) |
|
||||
| `stuck` | `downloading` | `Retry`, `reconcileRecovery` |
|
||||
| `stuck` | `completed` | `reconcileRecovery` (classReady) |
|
||||
| `stuck` | `cancelled` / `deferred` | `Cancel` / `Defer` (stuck не терминально) |
|
||||
| `failed` | `downloading` | `Retry`, `reconcileRecovery` |
|
||||
| `failed` | `completed` | `reconcileRecovery` |
|
||||
| `reverted` | `recognizing` | `Relink` |
|
||||
| `reverted` | `orphaned` / `deleted` | `reconcileToReality` (Relink: источник пропал) |
|
||||
| `cancelled` | `recognizing` | `Relink` |
|
||||
| `cancelled` | `orphaned` / `deleted` | `reconcileToReality` (Relink: источник пропал) |
|
||||
| `target_missing` | `recognizing` | `Relink` |
|
||||
| `target_missing` | `done` / `orphaned` / `deleted` | `reconcileDesync` / `reconcileToReality` (heal/preflight) |
|
||||
| `orphaned` | `done` / `target_missing` / `deleted` | `reconcileDesync` |
|
||||
| `deleted` | — | окончательно терминально; сверка его не переоценивает |
|
||||
|
||||
Правило `Cancel`/`Defer` — **любое не-терминальное → `cancelled`/`deferred`** (проверка
|
||||
`IsTerminal` на входе). Поэтому в графе `cancelled` и `deferred` — легальная цель из
|
||||
**каждого** не-терминального состояния (включая `linking` после краха, включая
|
||||
`catched`); это закрепляется тест-инвариантом, а не ручной аккуратностью (см. `tasks.md`
|
||||
3.6) — именно ручное перечисление рискует пропустить состояние.
|
||||
|
||||
`reconcileToReality` (preflight-приведение к реальности перед действием ревью, когда
|
||||
раздача исчезла из qBittorrent) — отдельный источник рёбер `→ orphaned/deleted` из
|
||||
пользовательских/ревью-состояний; из терминальных `reverted/cancelled` он проходит
|
||||
`SetDownloadState`, так как цель (`orphaned`/`deleted`) тоже терминальна и гард
|
||||
терминальности не срабатывает.
|
||||
|
||||
Заметки о намеренных исключениях (чтобы граф был тесным, а не «на всякий случай»):
|
||||
|
||||
- `failed/done → cancelled/deferred` **не** включены: это терминальные состояния,
|
||||
`Cancel`/`Defer` их отвергают на входе (`IsTerminal`).
|
||||
- Рёбра из терминальных (`failed`, `reverted`, `cancelled`, `target_missing`,
|
||||
`orphaned`) в активные состояния (`downloading`/`completed`/`recognizing`) в графе
|
||||
есть, но проходят только через `ActivateIfNoOtherActive` — см. «Ортогональность».
|
||||
|
||||
## Риск и его закрытие
|
||||
|
||||
Главный риск — **слишком тесный граф** (пропущенное ребро ломает реально работающий
|
||||
переход, у которого нет теста). Закрытие: (1) граф выведён построчно из кода выше;
|
||||
(2) весь существующий набор тестов воркера/store гоняет реальные переходы — если
|
||||
предикат отвергнёт хоть один, тесты покраснеют; (3) новый тест графа проверяет
|
||||
объявленные рёбра на проход и репрезентативные необъявленные — на отказ.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Декларативный граф переходов машины состояний
|
||||
|
||||
## Why
|
||||
|
||||
Легальность переходов машины состояний загрузки сейчас **нигде не записана явно**.
|
||||
Чтобы понять «из `downloading` куда можно», надо прочитать весь `worker` (три файла:
|
||||
`worker.go`, `reconcile.go`, `review.go`) плюс store-методы. Единственный
|
||||
механический гард в `store.setState` — грубый: он запрещает молча оживить
|
||||
терминальную задачу (`state NOT IN terminal` без `reviveOK`) и держит инвариант
|
||||
«одна активная на infohash» (в SQL-транзакциях). Но он **не проверяет само ребро**
|
||||
перехода: `SetDownloadState(review → done)` или `linking → completed` пройдут молча,
|
||||
хотя таких переходов машина не выполняет.
|
||||
|
||||
Это оставляет класс латентных багов без страховки: будущая правка воркера может
|
||||
записать состояние, которого граф не предусматривает, и мы узнаем об этом только по
|
||||
странице задачи в неверном состоянии.
|
||||
|
||||
Внешнюю библиотеку-FSM мы сознательно НЕ берём (см. `design.md`, «Почему не
|
||||
библиотека»): настоящий инвариант держится атомарно в SQLite и in-memory FSM в
|
||||
транзакции участвовать не может; наши переходы — это в основном сверка с реальностью
|
||||
qBittorrent, а не событийный автомат. Берём соразмерное: **свой декларативный граф**
|
||||
легальных рёбер в `internal/store`, который `setState` сверяет как дополнительный
|
||||
предикат, роняя необъявленный переход громко.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **ADDED Requirement: Легальность переходов задаётся декларативным графом** —
|
||||
единый источник истины `from → {разрешённые to}` в `internal/store`; переход, не
|
||||
объявленный ребром графа (и не идемпотентный самопереход `from == to`), запись
|
||||
состояния отклоняет.
|
||||
- Гейт встраивается в `store.setState` дополнительным SQL-предикатом
|
||||
`AND state IN (<легальные источники для to>)` — атомарно, без отдельного чтения;
|
||||
существующий гард терминальности и инвариант «одна активная на infohash»
|
||||
сохраняются без изменений и остаются **ортогональны** (граф говорит «ребро есть»,
|
||||
гард терминальности — «но не мимо `ActivateIfNoOtherActive`»).
|
||||
- Тест согласованности: граф хорошо сформирован (все состояния известны), объявленные
|
||||
рёбра проходят, необъявленные — отклоняются, а рёбра из терминальных состояний
|
||||
проходят только через revive-путь (`ActivateIfNoOtherActive`), но не через
|
||||
`SetDownloadState`.
|
||||
- Поведение существующих легальных переходов НЕ меняется: граф — надмножество всего,
|
||||
что воркер уже выполняет.
|
||||
|
||||
## Impact
|
||||
|
||||
- Specs: `download-tracking` (владелец прямого пути машины состояний).
|
||||
- Код: `internal/store/download.go` (`setState` + граф), новый тест графа. Воркер и
|
||||
транспорты — без изменений (граф прозрачен для легальных переходов).
|
||||
- Зависимости: ноль новых (принцип «минимум компонентов» соблюдён).
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# download-tracking Specification
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Легальность переходов задаётся декларативным графом
|
||||
|
||||
Множество легальных переходов машины состояний загрузки SHALL быть объявлено
|
||||
декларативно в едином месте (`internal/store`) как отображение `from →
|
||||
{разрешённые to}`, покрывающее все переходы, которые worker выполняет по всем
|
||||
capability (прямой путь, `state-reconciliation`, `review`). Этот граф SHALL быть
|
||||
единственным источником истины о легальности рёбер.
|
||||
|
||||
Запись состояния (`setState`, общая основа `SetDownloadState` и
|
||||
`ActivateIfNoOtherActive`) SHALL применять переход, только если он либо объявлен
|
||||
ребром графа, либо является идемпотентным самопереходом (`from == to`, переустановка
|
||||
того же состояния — например, повторная запись ошибки). Переход, не удовлетворяющий
|
||||
ни одному из условий, запись SHALL отклонять (0 строк UPDATE → ошибка), НЕ применяя
|
||||
его.
|
||||
|
||||
Гейт графа SHALL быть **ортогонален** остальным гардам записи и НЕ SHALL их ослаблять:
|
||||
существующий запрет молча оживить терминальную задачу (переход из терминального
|
||||
состояния разрешён только через `ActivateIfNoOtherActive` с проверкой владения
|
||||
хешами) и инвариант «не более одной активной загрузки на infohash» сохраняются. Как
|
||||
следствие, ребро из терминального состояния (напр. `failed → downloading` при retry)
|
||||
SHALL проходить только revive-путём (`ActivateIfNoOtherActive`) и SHALL отклоняться
|
||||
обычным `SetDownloadState`.
|
||||
|
||||
Граф SHALL быть надмножеством всех переходов, которые worker уже выполняет: введение
|
||||
гейта НЕ SHALL менять поведение существующих легальных переходов.
|
||||
|
||||
#### Scenario: Объявленный переход применяется
|
||||
|
||||
- **GIVEN** загрузка в состоянии `downloading`
|
||||
- **WHEN** worker записывает переход `downloading → completed` (объявленное ребро)
|
||||
- **THEN** состояние становится `completed`
|
||||
|
||||
#### Scenario: Необъявленный переход отклоняется
|
||||
|
||||
- **GIVEN** загрузка в состоянии `review`
|
||||
- **WHEN** делается попытка записать переход `review → done` (ребра в графе нет)
|
||||
- **THEN** запись отклоняется с ошибкой, состояние остаётся `review`
|
||||
|
||||
#### Scenario: Идемпотентная переустановка состояния разрешена
|
||||
|
||||
- **GIVEN** загрузка в состоянии `deferred`
|
||||
- **WHEN** записывается переход `deferred → deferred` (самопереход)
|
||||
- **THEN** запись проходит, состояние остаётся `deferred`
|
||||
|
||||
#### Scenario: Ребро из терминального состояния только через revive
|
||||
|
||||
- **GIVEN** загрузка в терминальном состоянии `failed`
|
||||
- **WHEN** переход `failed → downloading` делается обычным `SetDownloadState`
|
||||
- **THEN** запись отклоняется (терминальную задачу нельзя оживить мимо гарда владения)
|
||||
- **AND** тот же переход через `ActivateIfNoOtherActive` (при свободном infohash)
|
||||
проходит
|
||||
@@ -0,0 +1,50 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Граф в store
|
||||
|
||||
- [x] 1.1 Объявить `allowedTransitions map[State][]State` (from → to) в
|
||||
`internal/store/download.go` рядом с `terminalStates`, с комментарием об источнике
|
||||
истины и правиле самоперехода. Заполнить по таблице из `design.md`.
|
||||
- [x] 1.2 Построить обратное отображение `to → {легальные from}` (для SQL-предиката)
|
||||
как package-level `var` через хелпер-инвертор; включать сам `to` (самопереход).
|
||||
|
||||
## 2. Гейт в setState
|
||||
|
||||
- [x] 2.1 В `setState` добавить предикат `AND state IN (<легальные источники для to>)`
|
||||
до/рядом с существующим гардом терминальности; аргументы через `placeholders`.
|
||||
- [x] 2.2 На `n == 0` — диагностическое чтение текущего состояния (через
|
||||
`sqlx.QueryerContext`, если `e` его поддерживает) для точного сообщения:
|
||||
«not found» / «illegal transition <cur> → <to>» / терминал без revive.
|
||||
- [x] 2.3 Сверить `PromoteCatched`: ребро `catched → downloading` присутствует в
|
||||
графе; оставить его собственный гард `state='catched'`, добавить комментарий-ссылку
|
||||
на граф.
|
||||
|
||||
## 3. Тест согласованности
|
||||
|
||||
- [x] 3.1 `TestTransitionGraphWellFormed`: все состояния в ключах и значениях графа —
|
||||
известные (из полного списка `State`); ни один список не содержит сам ключ
|
||||
(петли не перечисляются явно).
|
||||
- [x] 3.2 `TestSetStateAllowsDeclaredEdges`: для набора объявленных не-revive рёбер
|
||||
(`downloading→completed`, `review→linking`, `recognizing→review`, …)
|
||||
`SetDownloadState` проходит.
|
||||
- [x] 3.3 `TestSetStateRejectsUndeclaredEdges`: репрезентативные необъявленные
|
||||
(`review→done`, `downloading→done`, `completed→linking`) отклоняются, состояние не
|
||||
меняется.
|
||||
- [x] 3.4 `TestSelfTransitionAllowed`: `deferred→deferred` проходит.
|
||||
- [x] 3.5 `TestTerminalReviveOnlyViaActivate`: `failed→downloading` через
|
||||
`SetDownloadState` отклоняется, а через `ActivateIfNoOtherActive` (при свободном
|
||||
infohash) проходит.
|
||||
- [x] 3.6 `TestCancelDeferReachableFromEveryNonTerminal`: инвариант generic-команд —
|
||||
для каждого не-терминального состояния `cancelled` — легальная цель, и `deferred` —
|
||||
легальная цель (кроме самого `deferred`, где это самопереход). Ловит класс дыры
|
||||
«забыли состояние» (напр. `linking` после краха).
|
||||
- [x] 3.7 `TestPreflightDesyncEdges`: рёбра `reconcileToReality` из ревью/терминальных
|
||||
состояний при пропавшем источнике — `review/deferred/reverted/cancelled →
|
||||
orphaned` и `→ deleted` — проходят.
|
||||
|
||||
## 4. Проверка отсутствия регрессий
|
||||
|
||||
- [x] 4.1 `task test` — весь набор зелёный (существующие тесты воркера/store — сеть
|
||||
безопасности против слишком тесного графа).
|
||||
- [x] 4.2 `task lint` — 0 issues.
|
||||
- [x] 4.3 `openspec validate state-transition-graph --strict`.
|
||||
Reference in New Issue
Block a user