Files
jellybit/openspec/specs/download-tracking/spec.md
T
avandClaude Opus 4.8 b8657120fe Приём: усыновление присутствующего в qBittorrent торрента вместо дубль-Add (409)
processCatched перед Add проверяет присутствие торрента в qBittorrent (один
листинг на тик): если раздача уже есть — усыновляем (promote catched→downloading
без повторного Add и без LLM-namer, имя из раздачи), иначе добавляем как раньше.
Это убирает бесконечный цикл дубль-Add → 409 → ретрай и лишние вызовы LLM.
Инвариант приёма «одна активная на infohash» делает различие «наш/чужой»
ненужным. source_type перечитывается под замком (сужение гонки апгрейда F6);
при недоступности qBittorrent тик пропускается без вызова LLM.

Дедуп на приёме (дубль на уже активную задачу) теперь отражается явным ответом
бота «дубль уже активной #id — добавление отменено».

Спека download-tracking обновлена (OpenSpec change заархивирован); закрыта
задача беклога review-f2-promote-without-add.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:36:47 +03:00

346 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# download-tracking Specification
## Purpose
Отслеживание скачивания и прямой путь машины состояний загрузки: поллинг
qBittorrent и сопоставление его состояний (downloading → completed; готовность
только когда файлы на месте), таймауты-предохранители (`magnet_timeout`/
`stuck_after`), ошибка qBit → failed, усыновление раздач по категории/тегу и
переходы под per-download блокировкой. Сверка уже разложенного с реальностью —
в `state-reconciliation`.
## Requirements
### Requirement: Поллинг qBittorrent и сопоставление состояний
Worker SHALL периодически (`worker.poll_interval`, дефолт 5 с) опрашивать
qBittorrent и сопоставлять его состояния раздачи с состоянием загрузки в БД.
Готовые к раскладке состояния (`uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/
`queuedUP`/`forcedUP`, с учётом различий имён между qBit v4 и v5) SHALL переводить
загрузку в `completed`. Ещё качающиеся состояния (`downloading`/`stalledDL`/
`metaDL`/…) SHALL оставлять её в `downloading`.
#### Scenario: Раздача завершилась
- **GIVEN** загрузка в `downloading`
- **WHEN** qBittorrent сообщает состояние `stalledUP` и файлы на месте
- **THEN** загрузка переходит в `completed`
### Requirement: Готовность только когда файлы на месте
Переходные состояния qBittorrent система SHALL трактовать как «ждём»
(`moving`/`checkingUP`/`checkingResumeData`/`allocating`): оставаться в
`downloading` и НЕ объявлять готовность, даже если выставлены флаги `UP`, пока
qBit не завершит перенос/проверку. Финальные пути файлов система SHALL брать из
API после завершения переноса.
#### Scenario: Ждём завершения переноса
- **GIVEN** загрузка, у которой qBittorrent в состоянии `moving`
- **WHEN** идёт тик поллинга
- **THEN** загрузка остаётся в `downloading`, готовность не объявляется
### Requirement: Таймауты-предохранители downloading
Система SHALL переводить `metaDL`/`forcedMetaDL` дольше `magnet_timeout` (дефолт
`24h`, редкий предохранитель) в `failed` (`error_code` `magnet_timeout`), а
`stalledDL` дольше `stuck_after` — в `stuck` (`error_code`
`stalled`). Возраст система SHALL считать от времени добавления в qBittorrent
(`added_on`), а не от создания задачи, чтобы базис переживал retry и усыновление.
Долгий `metaDL` система НЕ SHALL убивать агрессивно (медленные трекеры — норма).
#### Scenario: Завис на метаданных дольше таймаута
- **GIVEN** раздача в `metaDL` дольше `magnet_timeout` от `added_on`
- **WHEN** идёт тик поллинга
- **THEN** загрузка переходит в `failed` с `error_code` `magnet_timeout`
### Requirement: Ошибка qBittorrent переводит в failed
Состояния `error`/`missingFiles` система SHALL трактовать как настоящий провал и
переводить загрузку в `failed` (`error_code` `qbit_error`) — в отличие от
таймаутов-предохранителей, такой провал сверкой не воскрешается.
#### Scenario: qBit сообщает об ошибке
- **GIVEN** раздача в состоянии `missingFiles`
- **WHEN** идёт тик поллинга
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_error`
### Requirement: Усыновление раздач по категории или тегу
Worker SHALL периодически сверять раздачи qBittorrent с БД и **усыновлять** те, у
которых наша категория (`qbittorrent.category`) ИЛИ тег (`qbittorrent.tag`), а
записи в БД ещё нет, заводя для них загрузку в состоянии `downloading`. Категория
ставится на добавляемые нами раздачи (push); тег позволяет подхватить уже
существующую раздачу (pull), не трогая её категорию и файлы.
#### Scenario: Подхват существующей раздачи по тегу
- **GIVEN** в qBittorrent есть раздача с тегом `qbittorrent.tag`, которой нет в БД
- **WHEN** worker сверяет qBittorrent с БД
- **THEN** для раздачи заводится загрузка в состоянии `downloading`
### Requirement: Переходы состояний сериализуются воркером
Все переходы состояний загрузки SHALL сериализоваться worker'ом под единой
блокировкой (поллинг-цикл и команды всех транспортов проходят через неё), чтобы
два источника перехода не гонялись за одно состояние. Состояние SHALL быть
персистентным в SQLite; активность загрузки SHALL выводиться только из `state`,
без отдельного флага.
#### Scenario: Команды сериализуются
- **GIVEN** две одновременные команды к одной загрузке из разных транспортов
- **WHEN** они обрабатываются
- **THEN** переходы применяются последовательно под блокировкой, без гонки
### Requirement: Добавление пойманной загрузки в qBittorrent
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
подхватывать загрузки в состоянии `catched` и для каждой (кроме случая уже
присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из
контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить
источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и
перевести загрузку `catched → downloading`. Отдельного состояния между `catched`
и `downloading` быть SHALL NOT — успешный `add` сразу переводит в `downloading`
(которое и означает «в qBit, возможно `metaDL`»).
Перед добавлением worker SHALL проверять, **присутствует ли торрент загрузки уже
в qBittorrent** (по любому из её infohash), опираясь на листинг раздач того же
тика. Если торрент уже присутствует, worker SHALL **усыновить** его: перевести
загрузку `catched → downloading` **без повторного `add`** и без вывода имени
через LLM (`display_name` берётся из имени присутствующей раздачи). Повторный
`add` здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. `409
Conflict`), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше
идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL
выполняться **до вывода отображаемого имени**, чтобы не тратить LLM-вызов на
загрузку, которую добавлять не требуется.
Инвариант приёма («одна активная загрузка на infohash», см. `ingest`) гарантирует,
что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой
активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как
«усыновить и разложить», а не как конфликт с чужой задачей.
Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную
загрузку в этот тик трогать SHALL NOT (ни `add`, ни namer) и повторить на
следующем; устойчивая недоступность отсекается предохранителем `catch_timeout`
(см. «Предохранитель зависшего catched»).
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
(`source_type`):
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
загрузке при приёме) и передавать их **файлом** (`torrents` API
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
magnet-хешу вместо файла система SHALL NOT.
`source_type` для выбора способа добавления worker SHALL перечитывать **под
блокировкой переходов** непосредственно перед добавлением (а не полагаться на
снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом
пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из
устаревшего снимка, хотя БД уже `torrent`.
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
загрузку в `catched` для повторной попытки на следующем тике; переход в
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
естественными тиками поллинга, отсечка — `catch_timeout`).
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией,
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
параллельной отмене).
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
ещё нет в qBittorrent
- **WHEN** worker обрабатывает тик
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
нашей категорией и `rename`
- **AND** загрузка переходит в `downloading`
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
сохранёнными байтами файла, торрента ещё нет в qBittorrent
- **WHEN** worker обрабатывает тик
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
нашей категорией и `rename`, без обращения к magnet-хешу
- **AND** загрузка переходит в `downloading`
#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в
qBittorrent (добавлен ранее вручную/другим клиентом либо `add` прошёл на
прошлом тике, а запись перехода не удалась)
- **WHEN** worker обрабатывает тик
- **THEN** worker НЕ вызывает `qbt.Add` и НЕ выводит отображаемое имя через LLM
- **AND** `display_name` записывается из имени присутствующей раздачи
- **AND** загрузка переходит в `downloading` и идёт обычным путём к раскладке
#### Scenario: qBittorrent недоступен при проверке присутствия — повтор
- **GIVEN** загрузка в `catched`, листинг раздач qBittorrent не удался
- **WHEN** worker обрабатывает тик
- **THEN** worker НЕ вызывает namer и НЕ добавляет источник
- **AND** загрузка остаётся в `catched` и попытка повторяется на следующем тике
#### Scenario: Временный сбой добавления — повтор
- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался
- **WHEN** worker пытается добавить источник и `add` возвращает ошибку
- **THEN** загрузка остаётся в `catched`
- **AND** на следующем тике попытка добавления повторяется
#### Scenario: Отмена во время добавления
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
worker берёт блокировку для записи перехода
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
`downloading` не применяется
### Requirement: Предохранитель зависшего catched
Система SHALL переводить загрузку, задержавшуюся в `catched` дольше
`catch_timeout` (конфигурируемый предохранитель, дефолт консервативный), в
`failed` (`error_code` `qbit_add`) и уведомлять автора. Возраст SHALL считать
от времени попадания в `catched` (создания загрузки). Предохранитель —
редкий страховочный механизм на случай устойчивой недоступности qBittorrent, а
не штатный путь.
#### Scenario: catched висит дольше таймаута
- **GIVEN** загрузка в `catched` дольше `catch_timeout`
- **WHEN** идёт тик поллинга
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add`
- **AND** автор загрузки уведомляется
### Requirement: catched не считается пропажей раздачи
Система SHALL исключать состояние `catched` из проверок «раздача не найдена в
qBittorrent» — как в поллинге активных загрузок, так и в сверке рассинхрона
(`state-reconciliation`). У пойманной загрузки раздачи в qBittorrent ещё нет по
дизайну, поэтому её отсутствие система SHALL NOT трактовать как рассинхрон,
`orphaned` или пропажу источника.
#### Scenario: Отсутствие раздачи у catched — не рассинхрон
- **GIVEN** загрузка в `catched` (раздачи в qBittorrent ещё нет)
- **WHEN** идёт тик поллинга и сверки
- **THEN** загрузка не считается пропавшей/рассинхронизированной и остаётся в
`catched` (до добавления воркером или срабатывания `catch_timeout`)
### Requirement: Легальность переходов задаётся декларативным графом
Множество легальных переходов машины состояний загрузки SHALL быть объявлено
декларативно в едином месте (`internal/store`) как отображение `from →
{разрешённые to}`, покрывающее все переходы, которые worker выполняет по всем
capability (прямой путь, `state-reconciliation`, `review`). Этот граф SHALL быть
единственным источником истины о легальности рёбер.
Запись состояния (`setState`, общая основа `SetDownloadState` и
`ActivateIfNoOtherActive`) SHALL применять переход, только если он либо объявлен
ребром графа, либо является идемпотентным самопереходом (`from == to`, переустановка
того же состояния — например, повторная запись ошибки). Переход, не удовлетворяющий
ни одному из условий, запись SHALL отклонять (0 строк UPDATE → ошибка), НЕ применяя
его.
Гейт графа SHALL быть **ортогонален** остальным гардам записи и НЕ SHALL их ослаблять:
существующий запрет молча оживить терминальную задачу (переход из терминального
состояния разрешён только через `ActivateIfNoOtherActive` с проверкой владения
хешами) и инвариант «не более одной активной загрузки на infohash» сохраняются. Как
следствие, ребро из терминального состояния (напр. `failed → downloading` при retry)
SHALL проходить только revive-путём (`ActivateIfNoOtherActive`) и SHALL отклоняться
обычным `SetDownloadState`.
Граф SHALL быть надмножеством всех переходов, которые worker уже выполняет: введение
гейта НЕ SHALL менять поведение существующих легальных переходов.
#### Scenario: Объявленный переход применяется
- **GIVEN** загрузка в состоянии `downloading`
- **WHEN** worker записывает переход `downloading → completed` (объявленное ребро)
- **THEN** состояние становится `completed`
#### Scenario: Необъявленный переход отклоняется
- **GIVEN** загрузка в состоянии `review`
- **WHEN** делается попытка записать переход `review → done` (ребра в графе нет)
- **THEN** запись отклоняется с ошибкой, состояние остаётся `review`
#### Scenario: Идемпотентная переустановка состояния разрешена
- **GIVEN** загрузка в состоянии `deferred`
- **WHEN** записывается переход `deferred → deferred` (самопереход)
- **THEN** запись проходит, состояние остаётся `deferred`
#### Scenario: Ребро из терминального состояния только через revive
- **GIVEN** загрузка в терминальном состоянии `failed`
- **WHEN** переход `failed → downloading` делается обычным `SetDownloadState`
- **THEN** запись отклоняется (терминальную задачу нельзя оживить мимо гарда владения)
- **AND** тот же переход через `ActivateIfNoOtherActive` (при свободном infohash)
проходит
### Requirement: Пропажа источника у активной загрузки
Поллинг активных загрузок (`downloading`) SHALL обнаруживать пропажу источника:
если раздача, совпадающая с любым из известных хешей загрузки, отсутствует в
выдаче qBittorrent, система SHALL применять **тот же дебаунс пропажи источника**,
что и сверка рассинхрона (счётчик `source_miss_count`, порог
`[worker].source_missing_threshold`; см. `state-reconciliation` «Дебаунс пропажи
источника»). Любое обнаружение раздачи SHALL сбрасывать счётчик.
После `N` подряд идущих тиков без раздачи (`N =
[worker].source_missing_threshold`) система SHALL переводить загрузку
`downloading → failed` с `error_code` `source_gone` и уведомлять автора. До
достижения порога загрузка SHALL оставаться в `downloading` (транзиентная
недоступность qBittorrent, например рестарт демона, не должна ронять задачу).
`source_gone` система SHALL трактовать как отдельную причину, отличную от
`qbit_error` (реальная ошибка qBittorrent) и от `magnet_timeout`/`stalled` (наша
нетерпеливость). Восстановлению сверкой (`reconcileRecovery`) `source_gone`
подлежать SHALL NOT — удаление источника из qBittorrent намеренно, молча
воскрешать задачу нельзя. Задача SHALL оставаться штатно восстановимой вручную
(`Retry` заново отдаёт сохранённый источник в qBittorrent).
Состояние `catched` этим правилом затрагиваться SHALL NOT: у пойманной загрузки
раздачи в qBittorrent ещё нет по дизайну (см. «catched не считается пропажей
раздачи»), а цикл активных загрузок листает только `downloading`.
#### Scenario: Источник пропал у активной загрузки дольше порога
- **GIVEN** загрузка в `downloading`, чья раздача удалена из qBittorrent
- **WHEN** раздача отсутствует `source_missing_threshold` подряд идущих тиков
- **THEN** загрузка переходит в `failed` с `error_code` `source_gone`
- **AND** автор загрузки уведомляется
#### Scenario: Кратковременная пропажа источника не роняет задачу
- **GIVEN** загрузка в `downloading`
- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold`
тиков подряд
- **THEN** загрузка остаётся в `downloading`
#### Scenario: Возврат раздачи сбрасывает счётчик
- **GIVEN** загрузка в `downloading` с накопленными промахами источника (меньше
порога)
- **WHEN** раздача снова обнаружена в qBittorrent
- **THEN** счётчик промахов сбрасывается в ноль и загрузка ведётся обычной
сверкой состояния
#### Scenario: source_gone не воскрешается сверкой
- **GIVEN** загрузка в `failed` с `error_code` `source_gone`
- **WHEN** её раздача снова появляется в qBittorrent и продвигается
- **THEN** сверка восстановления её не трогает — задача остаётся в `failed` до
ручного `Retry`