diff --git a/docs/backlog/README.md b/docs/backlog/README.md index c603012..3be4450 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -37,7 +37,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог - [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать… - [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку - [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags -- [Defer из catched → лимбо → необратимый deleted (MAJOR-6)](review-major6-defer-catched.md) — Defer из ещё-не-добавленного catched уводит задачу в необратимый deleted _(ревью 2026-07-08)_ - [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_ ## Низкий diff --git a/docs/backlog/review-major6-defer-catched.md b/docs/backlog/review-major6-defer-catched.md deleted file mode 100644 index 1c6f493..0000000 --- a/docs/backlog/review-major6-defer-catched.md +++ /dev/null @@ -1,11 +0,0 @@ -# Defer из catched → лимбо → необратимый deleted (MAJOR-6) - -**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle - -Ревью Fable 2026-07-08 (жизненный цикл). review.go:465-479, worker.go:332-340, reconcile.go:251-265. - -Сценарий: задача в catched (ещё не добавлена в qBit) → user Defer → deferred. processCatched больше её не видит (листит только catched) → торрент никогда не добавится. Из deferred: Apply→«нет плана», Rerecognize/Refine→ensureSourcePresent нет торрента→reconcileToReality(sourcePresent=false, targetPresent=false)→deriveState=deleted, а у deleted НОЛЬ исходящих рёбер (download.go:102) → задача необратима, хотя байты .torrent лежат в download_torrent. Также deleted семантически неверен (ничего не качалось/раскладывалось). - -Фикс: исключить catched из Defer (пре-источниковое состояние, «позже» бессмысленно) ИЛИ processCatched резюмит deferred-без-recognition ИЛИ preflight «источника не было никогда» (source_added_at IS NULL и нет links) → failed/qbit_add (retriable), не deleted. - -Вердикт: простой фикс (исключить catched из Defer). diff --git a/internal/store/download.go b/internal/store/download.go index fcab92d..50c9ae1 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -78,9 +78,10 @@ func (s State) IsTerminal() bool { // (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в // setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive». // Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется. -// - deferred — легальная цель из КАЖДОГО не-терминального состояния (Defer -// проверяет лишь IsTerminal); инвариант закреплён тестом, а не ручной -// аккуратностью. +// - deferred — легальная цель из каждого не-терминального состояния, КРОМЕ +// пре-источникового catched (Defer его отклоняет: раздачи в qBittorrent ещё +// нет, откладывать нечего, а catched → deferred увёл бы задачу в лимбо — +// MAJOR-6). Инвариант закреплён тестом, а не ручной аккуратностью. // - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо // Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный // и из терминальных (done/failed/reverted/target_missing/orphaned) — только @@ -90,7 +91,7 @@ func (s State) IsTerminal() bool { // Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе // setState отклонит переход (0 строк UPDATE → ошибка). var allowedTransitions = map[State][]State{ - StateCatched: {StateDownloading, StateFailed, StateCancelled, StateDeferred}, + StateCatched: {StateDownloading, StateFailed, StateCancelled}, StateDownloading: {StateCompleted, StateFailed, StateStuck, StateCancelled, StateDeferred}, StateCompleted: {StateRecognizing, StateCancelled, StateDeferred}, StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred}, diff --git a/internal/store/transition_test.go b/internal/store/transition_test.go index ead7539..14520af 100644 --- a/internal/store/transition_test.go +++ b/internal/store/transition_test.go @@ -74,18 +74,25 @@ func TestTransitionGraphWellFormed(t *testing.T) { } } -// Инвариант Defer: deferred — легальная цель из КАЖДОГО не-терминального -// состояния (кроме самого deferred — это самопереход). Ловит класс дыры «забыли -// состояние» (напр. linking после краха процесса). -func TestDeferReachableFromEveryNonTerminal(t *testing.T) { +// Инвариант Defer: deferred — легальная цель из каждого не-терминального +// состояния, КРОМЕ пре-источникового catched (Defer его отклоняет: раздачи в +// qBittorrent ещё нет, откладывать нечего — MAJOR-6) и самого deferred (это +// самопереход). Ловит класс дыры «забыли состояние» (напр. linking после краха +// процесса). +func TestDeferReachableFromEveryNonTerminalButCatched(t *testing.T) { for _, s := range allStates { - if s.IsTerminal() || s == StateDeferred { - continue // deferred → deferred покрыт самопереходом + if s.IsTerminal() || s == StateDeferred || s == StateCatched { + continue // deferred → deferred покрыт самопереходом; catched исключён } if !slices.Contains(transitionSources[StateDeferred], s) { - t.Errorf("%s → deferred не легально (Defer допускает любое не-терминальное)", s) + t.Errorf("%s → deferred не легально (Defer допускает любое не-терминальное, кроме catched)", s) } } + // Пре-источниковое catched → deferred не легально: ребро снято из графа + // заодно с гардом в Worker.Defer. + if slices.Contains(transitionSources[StateDeferred], StateCatched) { + t.Errorf("catched → deferred легально, но должно быть снято (Defer отклоняет catched)") + } } // Инвариант универсального стоп-крана Dismiss: cancelled — легальная цель из diff --git a/internal/worker/review.go b/internal/worker/review.go index d5c2ee0..c7b1279 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -550,6 +550,12 @@ func (w *Worker) Defer(ctx context.Context, id string) (err error) { if d.State.IsTerminal() { return fmt.Errorf("defer: download %s is terminal (%s): %w", id, d.State, ErrConflict) } + // Пре-источниковое catched (торрент ещё не добавлен в qBittorrent) откладывать + // нечего: задача не дошла до ревью, а catched → deferred увёл бы её в лимбо — + // processCatched листает только catched и больше её не подхватит (MAJOR-6). + if d.State == store.StateCatched { + return fmt.Errorf("defer: источник ещё не добавлен в qBittorrent (%s), отложить можно после добавления: %w", d.State, ErrConflict) + } ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash()) w.transition(ctx, *d, store.StateDeferred, "", "") return nil diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 5375485..316dab8 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -944,6 +944,25 @@ func TestDefer(t *testing.T) { } } +// Defer из пре-источникового catched отклоняется конфликтом: торрент ещё не +// добавлен в qBittorrent, откладывать нечего, а catched → deferred увёл бы +// задачу в лимбо → необратимый deleted (MAJOR-6). Состояние не меняется. +func TestDeferRejectsCatched(t *testing.T) { + st := newMemStore() + d := completedDownload("1") + d.State = store.StateCatched + st.put(d) + w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + + err := w.Defer(context.Background(), "1") + if !errors.Is(err, ErrConflict) { + t.Fatalf("Defer from catched = %v, want ErrConflict", err) + } + if st.downloads["1"].State != store.StateCatched { + t.Errorf("state = %q, want catched (не тронуто)", st.downloads["1"].State) + } +} + // applyFixture — реальный layouter с temp-библиотеками и исходными файлами. type applyFixture struct { w *Worker diff --git a/openspec/changes/archive/2026-07-17-defer-exclude-catched/.openspec.yaml b/openspec/changes/archive/2026-07-17-defer-exclude-catched/.openspec.yaml new file mode 100644 index 0000000..ff5f854 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-defer-exclude-catched/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-17 diff --git a/openspec/changes/archive/2026-07-17-defer-exclude-catched/design.md b/openspec/changes/archive/2026-07-17-defer-exclude-catched/design.md new file mode 100644 index 0000000..b176edb --- /dev/null +++ b/openspec/changes/archive/2026-07-17-defer-exclude-catched/design.md @@ -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) не нужны — вердикт задачи +«простой фикс». diff --git a/openspec/changes/archive/2026-07-17-defer-exclude-catched/proposal.md b/openspec/changes/archive/2026-07-17-defer-exclude-catched/proposal.md new file mode 100644 index 0000000..d0e39d6 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-defer-exclude-catched/proposal.md @@ -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`». diff --git a/openspec/changes/archive/2026-07-17-defer-exclude-catched/specs/review/spec.md b/openspec/changes/archive/2026-07-17-defer-exclude-catched/specs/review/spec.md new file mode 100644 index 0000000..ec5195b --- /dev/null +++ b/openspec/changes/archive/2026-07-17-defer-exclude-catched/specs/review/spec.md @@ -0,0 +1,95 @@ +## MODIFIED Requirements + +### Requirement: Команды ревью и их эффекты + +Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по +эффективному плану), **Уточнить** (добавить подсказку → перераспознать), +**Распознать заново** (повторный прогон без новой подсказки), **Игнор файла**, +**Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные +ссылки → `reverted`) и **Привязать заново** (из +`reverted`/`cancelled`/`target_missing` → перераспознавание с ручным +подтверждением). Экран ревью MUST NOT содержать команду переключения типа +movie↔series: тип показывается read-only, а его корректировка выполняется +мягкой подсказкой через **Уточнить**. Команды из любого транспорта SHALL +сериализоваться worker'ом под единой блокировкой; применяется последняя валидная +команда. + +Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого +не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL +отклонять её из **пре-источникового** состояния `catched` (торрент ещё НЕ +добавлен в qBittorrent) — конфликтом (`ErrConflict`) с понятным пользователю +сообщением, НЕ меняя состояние загрузки. Пре-источниковое `catched` — +единственное состояние без раздачи среди не-терминальных: откладывать в нём +нечего (задача ещё не дошла до ревью), а `catched → deferred` уводил бы задачу в +лимбо — `processCatched` листает только `catched` и больше её не подхватит, а +последующие команды через отсутствие источника выводят необратимый `deleted`. +Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`). + +Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать +заново**, **Привязать заново**, а также фиксация типа), SHALL синхронно (без +дебаунса) проверять перед действием, что источник не только присутствует в +qBittorrent, но и **готов к раскладке** — раздача в готовом классе состояния +(`uploading`/`stalledUP`/`pausedUP`/… с учётом различий имён qBit v4/v5), +т.е. файлы докачаны. Если источник ещё качается (любое `downloading`-подобное +или переходное `moving`/`checking` состояние), команда SHALL отказывать с +конфликтом и причиной «торрент ещё качается», НЕ создавая хардлинки и НЕ меняя +состояние загрузки (её нахождение в `review`/`deferred`/… легитимно, приводить +к реальности нечего). Отсутствие источника в qBittorrent SHALL по-прежнему +приводить состояние к реальности (`orphaned`/`deleted`) и отказывать. Так +недокачанная задача не может пройти через перераспознавание в авто-раскладку +или ручное применение и захардлинкать неполные файлы, обойдя финальность +состояния `completed`. + +#### Scenario: Применение создаёт раскладку + +- **GIVEN** загрузка в `review` с эффективным планом +- **WHEN** пользователь выбирает «Применить» +- **THEN** создаются хардлинки по плану, задача переходит к раскладке + +#### Scenario: Отклонить и привязать заново + +- **GIVEN** загрузка в `review` +- **WHEN** пользователь «Отклонить», затем «Привязать заново» +- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным + подтверждением (авто-раскладка не делается) + +#### Scenario: Тип не переключается кнопкой + +- **GIVEN** загрузка в `review` с распознанным типом +- **WHEN** пользователь открывает экран ревью +- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет +- **AND** тип показан read-only в инфо-части выбранного источника + +#### Scenario: Позже паркует задачу из ревью + +- **GIVEN** загрузка в `review` (раздача в qBittorrent уже есть) +- **WHEN** пользователь выбирает «Позже» +- **THEN** задача переходит в `deferred` и возвращается на поверхность ревью по + любому последующему действию + +#### Scenario: Позже отклоняется для пре-источникового catched + +- **GIVEN** загрузка в `catched` (торрент ещё не добавлен в qBittorrent) +- **WHEN** приходит команда «Позже» (`Defer`, напр. прямым POST на + `/ui/downloads/{id}/defer`) +- **THEN** команда отклоняется конфликтом с понятным сообщением, что отложить + можно только после добавления торрента +- **AND** загрузка остаётся в `catched` и штатно доходит до `downloading` через + `processCatched` + +#### Scenario: Недокачанный источник отклоняет перераспознавание + +- **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё + качается (`downloading`, файлы не докачаны) +- **WHEN** пользователь выбирает «Распознать заново» (или «Уточнить»/«Привязать + заново»/фиксацию типа) +- **THEN** команда отклоняется с конфликтом и причиной «торрент ещё качается» +- **AND** загрузка остаётся в `deferred`, хардлинки не создаются, авто-раскладка + не запускается + +#### Scenario: Недокачанный источник отклоняет ручное применение + +- **GIVEN** загрузка в `review`, чья раздача в qBittorrent ещё качается +- **WHEN** пользователь выбирает «Применить» +- **THEN** команда отклоняется с конфликтом «торрент ещё качается», хардлинки + на неполные файлы не создаются, состояние загрузки не меняется diff --git a/openspec/changes/archive/2026-07-17-defer-exclude-catched/tasks.md b/openspec/changes/archive/2026-07-17-defer-exclude-catched/tasks.md new file mode 100644 index 0000000..82e0e24 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-defer-exclude-catched/tasks.md @@ -0,0 +1,30 @@ +## 1. Код + +- [x] 1.1 `internal/worker/review.go` — в `Defer` после `GetDownload` добавить + гард: `d.State == store.StateCatched` → `ErrConflict` с понятным сообщением + (отложить можно только после добавления торрента в qBittorrent), не меняя + состояние. Существующий гард `IsTerminal()` оставить. +- [x] 1.2 `internal/store/download.go` — убрать `StateDeferred` из исходящих + `StateCatched` в `allowedTransitions`; уточнить комментарий инварианта + `deferred` («из КАЖДОГО не-терминального» → «кроме пре-источникового + `catched`»). + +## 2. Тесты + +- [x] 2.1 `internal/worker/review_test.go` — `Defer` из `catched` возвращает + `ErrConflict`, состояние остаётся `catched`; контроль — `Defer` из `review` + по-прежнему уводит в `deferred` (существующий `TestDefer`). +- [x] 2.2 `internal/store/transition_test.go` — обновить инвариант `deferred`: + легальная цель из каждого не-терминального состояния, КРОМЕ `catched` (и + самопереход `deferred`); добавить проверку, что `catched → deferred` не + легально. + +## 3. Спека + +- [x] 3.1 MODIFIED-требование «Команды ревью и их эффекты» в `review`; + `openspec validate --strict` зелёный. + +## 4. Проверка + +- [x] 4.1 `task test` и `task lint` зелёные; существующие тесты + worker/httpapi/store не сломаны. diff --git a/openspec/specs/review/spec.md b/openspec/specs/review/spec.md index 634a826..7f5bd4d 100644 --- a/openspec/specs/review/spec.md +++ b/openspec/specs/review/spec.md @@ -39,6 +39,17 @@ movie↔series: тип показывается read-only, а его корре сериализоваться worker'ом под единой блокировкой; применяется последняя валидная команда. +Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого +не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL +отклонять её из **пре-источникового** состояния `catched` (торрент ещё НЕ +добавлен в qBittorrent) — конфликтом (`ErrConflict`) с понятным пользователю +сообщением, НЕ меняя состояние загрузки. Пре-источниковое `catched` — +единственное состояние без раздачи среди не-терминальных: откладывать в нём +нечего (задача ещё не дошла до ревью), а `catched → deferred` уводил бы задачу в +лимбо — `processCatched` листает только `catched` и больше её не подхватит, а +последующие команды через отсутствие источника выводят необратимый `deleted`. +Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`). + Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать заново**, **Привязать заново**, а также фиксация типа), SHALL синхронно (без дебаунса) проверять перед действием, что источник не только присутствует в @@ -74,6 +85,23 @@ qBittorrent, но и **готов к раскладке** — раздача в - **THEN** отдельной команды/кнопки переключения movie↔series на экране нет - **AND** тип показан read-only в инфо-части выбранного источника +#### Scenario: Позже паркует задачу из ревью + +- **GIVEN** загрузка в `review` (раздача в qBittorrent уже есть) +- **WHEN** пользователь выбирает «Позже» +- **THEN** задача переходит в `deferred` и возвращается на поверхность ревью по + любому последующему действию + +#### Scenario: Позже отклоняется для пре-источникового catched + +- **GIVEN** загрузка в `catched` (торрент ещё не добавлен в qBittorrent) +- **WHEN** приходит команда «Позже» (`Defer`, напр. прямым POST на + `/ui/downloads/{id}/defer`) +- **THEN** команда отклоняется конфликтом с понятным сообщением, что отложить + можно только после добавления торрента +- **AND** загрузка остаётся в `catched` и штатно доходит до `downloading` через + `processCatched` + #### Scenario: Недокачанный источник отклоняет перераспознавание - **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё