Слияние: Defer отклоняет catched, снято мёртвое ребро (T3)
This commit is contained in:
@@ -35,7 +35,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)_
|
||||
|
||||
## Низкий
|
||||
|
||||
@@ -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).
|
||||
@@ -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},
|
||||
|
||||
@@ -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 — легальная цель из
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1008,6 +1008,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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-17
|
||||
@@ -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) не нужны — вердикт задачи
|
||||
«простой фикс».
|
||||
@@ -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`».
|
||||
@@ -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** команда отклоняется с конфликтом «торрент ещё качается», хардлинки
|
||||
на неполные файлы не создаются, состояние загрузки не меняется
|
||||
@@ -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 не сломаны.
|
||||
@@ -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 ещё
|
||||
|
||||
Reference in New Issue
Block a user