Жизненный цикл: 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:
@@ -37,7 +37,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
|||||||
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать…
|
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать…
|
||||||
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
||||||
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
|
- [Внешние субтитры: пары 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)_
|
- [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): гейт графа ортогонален гарду терминальности в
|
// (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в
|
||||||
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
|
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
|
||||||
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
|
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
|
||||||
// - deferred — легальная цель из КАЖДОГО не-терминального состояния (Defer
|
// - deferred — легальная цель из каждого не-терминального состояния, КРОМЕ
|
||||||
// проверяет лишь IsTerminal); инвариант закреплён тестом, а не ручной
|
// пре-источникового catched (Defer его отклоняет: раздачи в qBittorrent ещё
|
||||||
// аккуратностью.
|
// нет, откладывать нечего, а catched → deferred увёл бы задачу в лимбо —
|
||||||
|
// MAJOR-6). Инвариант закреплён тестом, а не ручной аккуратностью.
|
||||||
// - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо
|
// - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо
|
||||||
// Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный
|
// Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный
|
||||||
// и из терминальных (done/failed/reverted/target_missing/orphaned) — только
|
// и из терминальных (done/failed/reverted/target_missing/orphaned) — только
|
||||||
@@ -90,7 +91,7 @@ func (s State) IsTerminal() bool {
|
|||||||
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
|
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
|
||||||
// setState отклонит переход (0 строк UPDATE → ошибка).
|
// setState отклонит переход (0 строк UPDATE → ошибка).
|
||||||
var allowedTransitions = map[State][]State{
|
var allowedTransitions = map[State][]State{
|
||||||
StateCatched: {StateDownloading, StateFailed, StateCancelled, StateDeferred},
|
StateCatched: {StateDownloading, StateFailed, StateCancelled},
|
||||||
StateDownloading: {StateCompleted, StateFailed, StateStuck, StateCancelled, StateDeferred},
|
StateDownloading: {StateCompleted, StateFailed, StateStuck, StateCancelled, StateDeferred},
|
||||||
StateCompleted: {StateRecognizing, StateCancelled, StateDeferred},
|
StateCompleted: {StateRecognizing, StateCancelled, StateDeferred},
|
||||||
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
|
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
|
||||||
|
|||||||
@@ -74,18 +74,25 @@ func TestTransitionGraphWellFormed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инвариант Defer: deferred — легальная цель из КАЖДОГО не-терминального
|
// Инвариант Defer: deferred — легальная цель из каждого не-терминального
|
||||||
// состояния (кроме самого deferred — это самопереход). Ловит класс дыры «забыли
|
// состояния, КРОМЕ пре-источникового catched (Defer его отклоняет: раздачи в
|
||||||
// состояние» (напр. linking после краха процесса).
|
// qBittorrent ещё нет, откладывать нечего — MAJOR-6) и самого deferred (это
|
||||||
func TestDeferReachableFromEveryNonTerminal(t *testing.T) {
|
// самопереход). Ловит класс дыры «забыли состояние» (напр. linking после краха
|
||||||
|
// процесса).
|
||||||
|
func TestDeferReachableFromEveryNonTerminalButCatched(t *testing.T) {
|
||||||
for _, s := range allStates {
|
for _, s := range allStates {
|
||||||
if s.IsTerminal() || s == StateDeferred {
|
if s.IsTerminal() || s == StateDeferred || s == StateCatched {
|
||||||
continue // deferred → deferred покрыт самопереходом
|
continue // deferred → deferred покрыт самопереходом; catched исключён
|
||||||
}
|
}
|
||||||
if !slices.Contains(transitionSources[StateDeferred], s) {
|
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 — легальная цель из
|
// Инвариант универсального стоп-крана Dismiss: cancelled — легальная цель из
|
||||||
|
|||||||
@@ -550,6 +550,12 @@ func (w *Worker) Defer(ctx context.Context, id string) (err error) {
|
|||||||
if d.State.IsTerminal() {
|
if d.State.IsTerminal() {
|
||||||
return fmt.Errorf("defer: download %s is terminal (%s): %w", id, d.State, ErrConflict)
|
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())
|
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||||
w.transition(ctx, *d, store.StateDeferred, "", "")
|
w.transition(ctx, *d, store.StateDeferred, "", "")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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-библиотеками и исходными файлами.
|
// applyFixture — реальный layouter с temp-библиотеками и исходными файлами.
|
||||||
type applyFixture struct {
|
type applyFixture struct {
|
||||||
w *Worker
|
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'ом под единой блокировкой; применяется последняя валидная
|
сериализоваться worker'ом под единой блокировкой; применяется последняя валидная
|
||||||
команда.
|
команда.
|
||||||
|
|
||||||
|
Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого
|
||||||
|
не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL
|
||||||
|
отклонять её из **пре-источникового** состояния `catched` (торрент ещё НЕ
|
||||||
|
добавлен в qBittorrent) — конфликтом (`ErrConflict`) с понятным пользователю
|
||||||
|
сообщением, НЕ меняя состояние загрузки. Пре-источниковое `catched` —
|
||||||
|
единственное состояние без раздачи среди не-терминальных: откладывать в нём
|
||||||
|
нечего (задача ещё не дошла до ревью), а `catched → deferred` уводил бы задачу в
|
||||||
|
лимбо — `processCatched` листает только `catched` и больше её не подхватит, а
|
||||||
|
последующие команды через отсутствие источника выводят необратимый `deleted`.
|
||||||
|
Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`).
|
||||||
|
|
||||||
Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать
|
Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать
|
||||||
заново**, **Привязать заново**, а также фиксация типа), SHALL синхронно (без
|
заново**, **Привязать заново**, а также фиксация типа), SHALL синхронно (без
|
||||||
дебаунса) проверять перед действием, что источник не только присутствует в
|
дебаунса) проверять перед действием, что источник не только присутствует в
|
||||||
@@ -74,6 +85,23 @@ qBittorrent, но и **готов к раскладке** — раздача в
|
|||||||
- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет
|
- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет
|
||||||
- **AND** тип показан read-only в инфо-части выбранного источника
|
- **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: Недокачанный источник отклоняет перераспознавание
|
#### Scenario: Недокачанный источник отклоняет перераспознавание
|
||||||
|
|
||||||
- **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё
|
- **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё
|
||||||
|
|||||||
Reference in New Issue
Block a user