Слияние: Defer отклоняет catched, снято мёртвое ребро (T3)
This commit is contained in:
@@ -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