Жизненный цикл: 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:
@@ -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) не нужны — вердикт задачи
|
||||
«простой фикс».
|
||||
Reference in New Issue
Block a user