Жизненный цикл: 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,44 @@
## Context
Фикс простой (гард одной команды + снятие мёртвого ребра графа). Дизайн
фиксирует два решения, чтобы ревью дизайна прошло до кода.
## Решение 1: где гардить Defer
`Defer` (`review.go`) отличается от прочих команд ревью: он не использует
`requireReviewable` (review/deferred), а сознательно широк — паркует любую
не-терминальную задачу (downloading/completed/recognizing/review/linking/stuck/
deferred), потому что «отложить» осмысленно и для ещё качающейся задачи. Значит
фикс НЕ «сузить Defer до reviewable», а точечно исключить пре-источниковое
`catched`.
Гард добавляем в сам `Defer` после `GetDownload`, рядом с существующей проверкой
`IsTerminal()`: `if d.State == store.StateCatched { return ...ErrConflict }`.
`ErrConflict` уже маппится в `httpapi.classifyErr` → 409 «действие недоступно в
текущем состоянии» (см. docs/conventions/errors.md), новый sentinel не нужен.
Сообщение обёртки — операторская диагностика для логов; наружу транспорт отдаёт
нейтральный маппинг.
## Решение 2: граф переходов
`allowedTransitions[StateCatched]` содержит `StateDeferred`. После гарда это
ребро мёртвое (единственный переход в `deferred` — команда `Defer`). Убираем
`StateDeferred` из исходящих `catched`, чтобы граф оставался тесным
надмножеством реальных переходов (инвариант спеки «граф — источник истины о
легальности рёбер»). Обновляем комментарий инварианта `deferred` в download.go
(«из КАЖДОГО не-терминального» → «кроме пре-источникового catched») и тест
`transition_test.go`, который его закрепляет.
Проверка полноты пре-источниковых состояний: `catched` — единственное
не-терминальное состояние без раздачи в qBittorrent. От `downloading` и далее
раздача есть; приёмное падение до `downloading` — терминальный `failed`
(`qbit_add`), Defer его уже отклоняет через `IsTerminal()`. Значит достаточно
исключить `catched`.
## Границы scope
Не трогаем `processCatched`, `reconcile`, граф `deleted`: корень бага —
единственный вход в лимбо (`catched → deferred`), закрытие входа устраняет всю
цепочку. Альтернативы из файла задачи (резюме deferred в processCatched;
preflight «источника не было» → failed/qbit_add) не нужны — вердикт задачи
«простой фикс».