Ревью: preflight готовности источника для команд ревью (MAJOR-5)

Команды ревью проверяли только наличие раздачи в qBittorrent, но не её
готовность. Недокачанную задачу можно припарковать в deferred, затем
«Распознать заново» → recognizing → авто-раскладка (Rerecognize/Refine/
SetType не ставят force_review) → хардлинки на неполные файлы. Даже ручной
Apply не имел preflight завершённости.

Вводим ensureSourceReady (classify(t.State)==classReady) вместо
ensureSourcePresent во всех командах, которым нужен источник (Relink/
Rerecognize/Refine/SetType), и inline-проверку класса в Apply — последний
рубеж перед хардлинками. Недокачанный источник → отдельный sentinel
ErrNotReady (409) с actionable-текстом «торрент ещё качается» в web и
Telegram, без reconcile (состояние deferred/review легитимно).

Change review-readiness-preflight заархивирован, дельта влита в
openspec/specs/review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-08 16:40:49 +03:00
co-authored by Claude Opus 4.8
parent a43adea723
commit b1bca98738
16 changed files with 475 additions and 41 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-08
@@ -0,0 +1,107 @@
## Context
Preflight-проверки ревью не доверяют состоянию в БД и синхронно сверяют
источник с qBittorrent прямо перед действием. Сейчас эта сверка — только на
**присутствие** (`ensureSourcePresent``torrentByInfohash`, результат
торрента отбрасывается). Класс состояния торрента (`classify`) определяется
лишь в поллинге (`downloading → completed`) и в recovery. Дыра: между этими
слоями команды ревью могут ввести недокачанную задачу в `recognizing` (откуда
finishRecognition делает авто-раскладку при `Decision.Auto && !force_review`)
или прямо в `linking` (Apply) — и создать хардлинки на неполные файлы.
`classify(state) == classReady` уже есть (`internal/worker/worker.go`) и
используется в `worker.go`/`reconcile.go`. `ErrConflict` — доменная ошибка,
транслируемая транспортами (HTTP 409, сообщение в UI/Telegram).
## Goals / Non-Goals
**Goals:**
- Ни одна команда ревью не может привести к хардлинкам на недокачанные файлы.
- Единый, легко формулируемый инвариант: команда, вводящая задачу в активную
обработку или раскладку, работает только с готовым источником.
- Отказ недокачанного источника не разрушает легитимное состояние задачи.
**Non-Goals:**
- Менять поллинг/переходы `download-tracking` — путь `downloading → completed`
уже корректен, дыра только в ручных командах re-entry.
- Гарантировать готовность на весь горизонт распознавания (см. Risks — гонка
«проверили → LLM думает»); монотонность прогресса делает риск пренебрежимым,
а Apply-гейт закрывает финальный рубеж.
## Decisions
### D1. `ensureSourceReady` заменяет `ensureSourcePresent`, тот же контракт отказа
Новый метод `ensureSourceReady(ctx, d, op)` в `reconcile.go`: берёт торрент
через `torrentByInfohash`; если не найден — `reconcileToReality(false)` +
`ErrConflict «источник удалён из qBittorrent»` (как сейчас); если найден, но
`classify(t.State) != classReady``ErrNotReady «op: торрент ещё качается»`
**без** `reconcileToReality` (см. D4 про отдельный sentinel).
Единая точка держит инвариант в одном месте и одинаково сообщает причину для
всех команд. Все четыре вызова `ensureSourcePresent` в review.go заменяются на
ready-вариант, других вызовов нет — старый метод становится мёртвым и
**удаляется** (не оставляем неиспользуемый путь).
### D2. Не звать `reconcileToReality`, когда источник есть, но не готов
`reconcileToReality` выводит состояние из (`sourcePresent`, `targetPresent`) —
для «источник есть, качается» правильного целевого состояния нет: задача
легитимно в `deferred`/`review`. Приводить нечего — просто отказываем. Это
отличает «ещё качается» (временный отказ, задача не трогается) от «источник
исчез» (сверка к `orphaned`/`deleted`).
### D3. Apply гейтит готовность inline, не через `ensureSourceReady`
`Apply` уже берёт торрент сам (`torrentByInfohash` для `SavePath`) и при
отсутствии зовёт `reconcileToReality`. Добавляем проверку класса на уже
полученном торренте (`classify(t.State) != classReady → ErrNotReady «торрент
ещё качается»`), не делая второй запрос к qBittorrent. Так Apply — последний
рубеж перед `linkPlan`, даже если задача пришла в `review` иным путём.
### D4. Отдельный sentinel `ErrNotReady` с конкретным сообщением
Причина «торрент ещё качается» actionable (жди докачки) и не совпадает по
смыслу с обычным конфликтом состояния, поэтому не прячем её за генерик
`ErrConflict`. Вводим отдельный `worker.ErrNotReady` (409, как и `ErrConflict`,
но со своим текстом) — по образцу уже существующих `errManualSource`/
`errInvalidCandidate`, чьи `.Error()` показываются пользователю. `ensureSourceReady`
и inline-проверка в `Apply` оборачивают им отказ:
`fmt.Errorf("%s: торрент ещё качается: %w", op, ErrNotReady)`.
Трансляция в транспортах:
- **HTTP/htmx** (`internal/httpapi`): в `classifyErr` добавить кейс
`errors.Is(err, worker.ErrNotReady) → 409, «торрент ещё качается, дождитесь
докачки»` **выше** кейса `ErrConflict` (иначе, если сделать их
`errors.Is`-совместимыми, перехватит первый; делаем sentinel независимым от
`ErrConflict`, порядок кейсов роли тогда не играет, но держим явным).
- **Telegram** (`internal/tgbot`): в обработчике callback-действий добавить
ветку `errors.Is(err, worker.ErrNotReady)` → сообщение «Торрент ещё
качается…», иначе прежний генерик `opErr(...)`. Сырой `err.Error()` наружу
по-прежнему не отдаём — показываем фиксированный текст.
`ErrNotReady`**не** `errors.Is`-обёртка над `ErrConflict` (отдельная
sentinel-переменная): статус тот же (409), но текст различается, а смешение
усложнило бы `classifyErr`.
## Risks / Trade-offs
- **Гонка «проверили готовность → LLM распознаёт → авто-раскладка».** Между
ready-проверкой в команде и `finishRecognition` проходит вызов LLM. →
Прогресс докачки монотонен: готовый торрент готовым и остаётся (кроме редкой
перепроверки `checkingUP`, которая классифицируется как `classBusy` → не
ready, и раскладка просто не сматчится по путям). Финальный Apply-гейт (D3) и
сам факт, что авто-раскладка требует `Decision.Auto`, делают остаточный риск
пренебрежимым.
- **Строже к `Relink`, чем требовала задача.** Relink на недокачанном источнике
теперь отклоняется сразу, а не доходит до review. → На практике кандидаты
Relink (`reverted`/`cancelled`/`target_missing`) — уже завершённые раздачи,
гейт почти никогда не срабатывает; ранний отказ понятнее пользователю, чем
блок на последующем Apply.
- **qBittorrent недоступен.** Как и `ensureSourcePresent`: честный отказ
операции (ошибка проброшена), состояние не трогаем.
## Migration Plan
Чисто внутреннее ужесточение preflight, без миграций БД и изменения API/схем.
Деплой — обычная замена бинаря. Откат — возврат бинаря; данные не затрагиваются.
@@ -0,0 +1,56 @@
## Why
Команды ревью, которым нужен источник, проверяют лишь **наличие** раздачи в
qBittorrent (`ensureSourcePresent`), но не её **готовность** (файлы докачаны).
Недокачанную задачу можно легально припарковать в `deferred` (граф допускает
`*→deferred`), а затем «Распознать заново»: она уходит в `recognizing`, LLM
видит имена ещё не докачанных файлов (qBittorrent отдаёт их до завершения) и при
уверенном матче срабатывает авто-раскладка (Rerecognize/Refine/SetType не ставят
`force_review`) — хардлинки создаются на **неполные файлы**, задача уходит в
`done`, Jellyfin сканирует половину. Даже ручной `review → Применить` не имеет
preflight завершённости. Это обходит смысл состояния `completed`
(«готовность только когда файлы на месте») и портит целостность медиатеки —
самый опасный из выявленных дефектов жизненного цикла.
## What Changes
- Ввести синхронный preflight **готовности** источника `ensureSourceReady`:
источник должен не только присутствовать, но и быть в готовом к раскладке
классе (`classify(t.State) == classReady`).
- Заменить `ensureSourcePresent` на `ensureSourceReady` во **всех** командах
ревью, которым нужен источник: `Rerecognize`, `Refine`, `SetType`, `Relink`.
Единый инвариант — команда, вводящая задачу в активную обработку
(`recognizing`) или в раскладку, работает только с готовым источником.
- Добавить ту же проверку готовности в `Apply` (сейчас он берёт торрент
inline и не смотрит на его класс) — последний рубеж перед созданием
хардлинков.
- Недокачанный источник → отказ отдельным sentinel `ErrNotReady` (409) с
actionable-сообщением «торрент ещё качается», **без** `reconcileToReality`:
состояние `deferred`/`review`/… легитимно, приводить к реальности нечего —
просто не даём действовать. Транспорты (HTTP/htmx и Telegram) показывают
конкретный текст, а не генерик «действие недоступно».
## Capabilities
### New Capabilities
<!-- нет новых capability -->
### Modified Capabilities
- `review`: команды ревью, которым нужен источник, SHALL проверять его
**готовность** (файлы докачаны), а не только наличие; недокачанный источник
→ отказ без разрушающих действий.
## Impact
- Код: `internal/worker/errors.go` (новый sentinel `ErrNotReady`),
`internal/worker/reconcile.go` (новый `ensureSourceReady`, удаление
`ensureSourcePresent`), `internal/worker/review.go` (замена вызовов в
`Rerecognize`/`Refine`/`SetType`/`Relink`, добавление проверки в `Apply`).
- Транспорты: `internal/httpapi` (`classifyErr` — кейс `ErrNotReady` → 409 с
текстом «торрент ещё качается»), `internal/tgbot` (ветка `ErrNotReady` в
обработчике действий). Пользователь видит конкретную причину вместо тихой
авто-раскладки неполных файлов; трансляция — на внешней границе.
- Тесты: `internal/worker/review_test.go` — сценарии недокачанного источника
для затронутых команд.
- Данные: устраняет создание хардлинков на неполные файлы (целостность
медиатеки Jellyfin).
@@ -0,0 +1,67 @@
## MODIFIED Requirements
### Requirement: Команды ревью и их эффекты
Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по
эффективному плану), **Уточнить** (добавить подсказку → перераспознать),
**Распознать заново** (повторный прогон без новой подсказки), **Игнор файла**,
**Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные
ссылки → `reverted`) и **Привязать заново** (из
`reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
подтверждением). Экран ревью MUST NOT содержать команду переключения типа
movie↔series: тип показывается read-only, а его корректировка выполняется
мягкой подсказкой через **Уточнить**. Команды из любого транспорта SHALL
сериализоваться worker'ом под единой блокировкой; применяется последняя валидная
команда.
Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать
заново**, **Привязать заново**, а также фиксация типа), 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** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё
качается (`downloading`, файлы не докачаны)
- **WHEN** пользователь выбирает «Распознать заново» (или «Уточнить»/«Привязать
заново»/фиксацию типа)
- **THEN** команда отклоняется с конфликтом и причиной «торрент ещё качается»
- **AND** загрузка остаётся в `deferred`, хардлинки не создаются, авто-раскладка
не запускается
#### Scenario: Недокачанный источник отклоняет ручное применение
- **GIVEN** загрузка в `review`, чья раздача в qBittorrent ещё качается
- **WHEN** пользователь выбирает «Применить»
- **THEN** команда отклоняется с конфликтом «торрент ещё качается», хардлинки
на неполные файлы не создаются, состояние загрузки не меняется
@@ -0,0 +1,45 @@
## 1. Preflight готовности источника
- [x] 1.1 В `internal/worker/errors.go` добавить sentinel
`var ErrNotReady = errors.New(...)` (отдельный от `ErrConflict`).
- [x] 1.2 В `internal/worker/reconcile.go` добавить `ensureSourceReady(ctx, d, op)`:
найти торрент через `torrentByInfohash`; нет источника →
`reconcileToReality(false)` + `ErrConflict «источник удалён из qBittorrent»`;
есть, но `classify(t.State) != classReady``ErrNotReady «op: торрент ещё
качается»` без `reconcileToReality`.
- [x] 1.3 Заменить `ensureSourcePresent` на `ensureSourceReady` в командах
`Rerecognize`, `Refine`, `SetType`, `Relink` (`internal/worker/review.go`);
удалить осиротевший `ensureSourcePresent`.
- [x] 1.4 В `Apply` добавить проверку класса на уже полученном торренте
(`classify(t.State) != classReady → ErrNotReady «торрент ещё качается»`),
без второго запроса к qBittorrent.
## 2. Трансляция ошибки в транспортах
- [x] 2.1 `internal/httpapi` `classifyErr`: кейс
`errors.Is(err, worker.ErrNotReady) → 409, «торрент ещё качается, дождитесь
докачки»` (отдельно от генерик-`ErrConflict`).
- [x] 2.2 `internal/tgbot`: в обработчике callback-действий ветка
`errors.Is(err, worker.ErrNotReady)` → сообщение «Торрент ещё качается…»,
иначе прежний `opErr(...)`.
## 3. Тесты
- [x] 3.1 Тест: недокачанный источник (`downloading`-класс) отклоняет
`Rerecognize`/`Refine`/`SetType`/`Relink` с `ErrNotReady`, состояние задачи
не меняется, авто-раскладка не запускается.
- [x] 3.2 Тест: недокачанный источник отклоняет `Apply` — хардлинки не
создаются, состояние не меняется.
- [x] 3.3 Тест: готовый источник (`classReady`) пропускает те же команды как
раньше (регресс не сломан); отсутствие источника по-прежнему приводит к
реальности и отказывает.
- [x] 3.4 Тест транспорта: `classifyErr(ErrNotReady) == 409` с конкретным
текстом (`internal/httpapi`).
## 4. Ревью и сверка
- [x] 4.1 `task test` и `task lint` зелёные.
- [x] 4.2 Ревью кода (второй чекпоинт) перед archive.
- [x] 4.3 `openspec validate review-readiness-preflight --strict` зелёный;
удалить `docs/backlog/review-major5-readiness-preflight.md` из беклога и
строку из индекса (суть переехала в спеку).