Слияние: Defer отклоняет catched, снято мёртвое ребро (T3)

This commit is contained in:
av
2026-07-17 21:39:41 +03:00
12 changed files with 307 additions and 23 deletions
-1
View File
@@ -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).
+5 -4
View File
@@ -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},
+14 -7
View File
@@ -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 — легальная цель из
+6
View File
@@ -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
+19
View File
@@ -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 не сломаны.
+28
View File
@@ -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 ещё