Команда 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>
3.3 KiB
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) не нужны — вердикт задачи
«простой фикс».