Жизненный цикл: Defer запрещён из пре-источникового catched (MAJOR-6)

Команда Defer гардила только IsTerminal() и потому принимала catched
(торрент ещё не добавлен в qBittorrent). Defer из catched уводил задачу
в лимбо → необратимый deleted: processCatched листает только catched и
больше её не подхватывал, а последующие команды через отсутствие
источника выводили deleted (ноль исходящих рёбер), хотя байты .torrent
лежат в download_torrent.

- Worker.Defer отклоняет catched с ErrConflict (транслируется в 409 /
  редирект с сообщением); прочие не-терминальные состояния, где раздача
  уже есть, принимает как раньше.
- Снято мёртвое ребро графа catched → deferred (allowedTransitions);
  инвариант «deferred из каждого не-терминального» уточнён: кроме
  пре-источникового catched. catched — единственное состояние без
  раздачи среди не-терминальных.
- Тесты: Defer из catched отклоняется и не меняет состояние; инвариант
  графа обновлён + негативная проверка ребра.
- OpenSpec: MODIFIED «Команды ревью и их эффекты» (review) с позитивным
  и негативным сценариями; change заархивирован, дельта влита в спеку.
- Беклог: закрыта review-major6-defer-catched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-17 21:39:01 +03:00
co-authored by Claude Opus 4.8
parent 0354a8c96b
commit 098695011f
12 changed files with 307 additions and 23 deletions
@@ -0,0 +1,64 @@
## Why
Команда **Defer** («Позже») сейчас гардит только `IsTerminal()`, поэтому
принимает и пре-источниковое состояние `catched` (торрент ещё НЕ добавлен в
qBittorrent). Defer из `catched` уводит задачу в лимбо → необратимый `deleted`
(находка ревью MAJOR-6):
- `catched → deferred`: `processCatched` листает только `catched` и задачу
больше не видит → торрент никогда не добавится в qBittorrent.
- Из `deferred` дальше тупик: `Apply` → «нет плана»; `Rerecognize`/`Refine`
`ensureSourceReady` не находит раздачу → сверка (`sourcePresent=false`,
`targetPresent=false`) выводит `deleted`, а у `deleted` НОЛЬ исходящих рёбер
→ задача необратима, хотя байты `.torrent` лежат в `download_torrent`.
- Плюс `deleted` семантически неверен: у `catched` ничего не качалось и не
раскладывалось.
Defer до появления источника бессмысленен: «отложить на потом» нечего — задача
ещё не дошла до ревью. Пре-источниковое `catched` — единственное такое
состояние (все состояния от `downloading` и далее уже имеют раздачу в
qBittorrent; приёмное падение `qbit_add` терминально и Defer его уже отклоняет).
## What Changes
- **Defer отклоняет пре-источниковое состояние `catched`** с конфликтом
(`ErrConflict`) и понятным сообщением: отложить можно только после добавления
торрента в qBittorrent. Прочие не-терминальные состояния (`downloading`/
`completed`/`recognizing`/`review`/`linking`/`stuck`/`deferred`), где раздача
уже есть, Defer принимает как и раньше.
- **Граф переходов теряет ребро `catched → deferred`** — раз Defer его больше
не выполняет, ребро мёртвое; граф остаётся тесным надмножеством реальных
переходов. Инвариант «`deferred` — легальная цель из каждого не-терминального
состояния» уточняется: **кроме** пре-источникового `catched`.
- UI/HTTP уже не предлагает Defer для `catched`: кнопка «🕗 Позже» живёт только
на экране ревью (`review`/`deferred`), карточка `catched` лишь самополлингом
ждёт перехода в `downloading`. Прямой вызов Defer для `catched` теперь
отклоняется доменным гардом; транспорт транслирует отказ по своему каналу:
REST — 409 «действие недоступно в текущем состоянии» (`classifyErr`), веб-путь
`/ui/downloads/{id}/defer` — PRG-редирект (303) на `/review/{id}?err=…` с
нейтральным сообщением (как прочие отказы команд ревью).
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `review`: требование «Команды ревью и их эффекты» — уточняет допустимые
исходные состояния команды **Позже** (`Defer`): любое не-терминальное, кроме
пре-источникового `catched` (там нет раздачи и нечего откладывать).
## Impact
- **Спеки:** дельта `review` (одно MODIFIED-требование с негативным сценарием).
Требование графа переходов в `download-tracking` (декларативное, конкретные
рёбра не перечисляет) не меняется — снятие ребра `catched → deferred` из
единого источника истины в коде ему не противоречит.
- **Код:** `internal/worker/review.go``Defer` (гард против `catched`);
`internal/store/download.go` — убрать `StateDeferred` из исходящих `catched`
и уточнить комментарий инварианта `deferred`.
- **Тесты:** `internal/worker/review_test.go` — Defer из `catched` отклоняется,
из `review` по-прежнему работает; `internal/store/transition_test.go`
инвариант «`deferred` из каждого не-терминального, кроме `catched`».