Files
avandClaude Opus 4.8 b1bca98738 Ревью: 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>
2026-07-08 16:40:49 +03:00

8.5 KiB

Context

Preflight-проверки ревью не доверяют состоянию в БД и синхронно сверяют источник с qBittorrent прямо перед действием. Сейчас эта сверка — только на присутствие (ensureSourcePresenttorrentByInfohash, результат торрента отбрасывается). Класс состояния торрента (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) != classReadyErrNotReady «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/схем. Деплой — обычная замена бинаря. Откат — возврат бинаря; данные не затрагиваются.