Files
jellybit/openspec/changes/archive/2026-07-17-defer-exclude-catched/design.md
T
avandClaude Opus 4.8 098695011f Жизненный цикл: 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>
2026-07-17 21:39:01 +03:00

3.3 KiB
Raw Blame History

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