## 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/схем. Деплой — обычная замена бинаря. Откат — возврат бинаря; данные не затрагиваются.