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