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