# 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`) опрашивать
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`
(редкий предохранитель) в `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 перечитывать **под
блокировкой переходов после тик-снимка** (тик-снимок — снимок списка `catched`,
снятый на тике поллинга) — перед подготовкой запроса на
добавление, а не полагаясь на снимок списка `catched`, который к моменту
обработки загрузки уже устарел (блокировка между снятием списка и обработкой
отпускается). Иначе апгрейд пойманной magnet-задачи до `.torrent` (см.
`ingest`), легший между снятием списка и обработкой, был бы пропущен, и worker
добавил бы magnet из устаревшего снимка, хотя в БД уже `torrent`.

Если подготовка запроса не удалась (сохранённые байты `.torrent` недоступны),
worker `add` выполнять SHALL NOT и SHALL оставлять загрузку в `catched` для
повтора на следующем тике; отсечка — `catch_timeout`.

Между этим re-read и самим `add` остаётся окно: вывод отображаемого имени идёт
секунды вне блокировки (см. ниже), а запрос на добавление собран до него и по
свежей записи не пересобирается. Апгрейд magnet → `.torrent`, легший ровно в это
окно, worker не подхватывает и добавляет magnet-ссылку. Это **известное
ограничение с названной ценой**, а не гарантия: сегодня запрос собран до вывода
имени и по свежей записи не пересобирается. Закрывать ли окно кодом и каким
способом (пересборкой запроса или сравнением одного `source_type` под замком с
пропуском тика при расхождении) — открытый вопрос за границами этого
требования.

Ущерб окна ограничен, но не бесплатен, и спека называет его точно. На публичном
трекере (или при живом DHT) magnet доберёт метаданные и задача пойдёт штатно —
ущерба нет вовсе. На закрытом трекере, ради которого `.torrent` и подавался,
magnet без метаданных зависает в `metaDL`, и предохранитель `magnet_timeout`
уводит задачу в `failed` (см. «Таймауты-предохранители downloading»). Сама собой
задача из этого состояния не восстанавливается, и `Retry` её тоже не чинит —
почему, см. `state-reconciliation` «Ручной повтор зависшей/упавшей загрузки из
транспортов». Восстановление сегодня требует
ручного шага: убрать
зависшую magnet-раздачу из qBittorrent, после чего `Retry` добавит источник
заново по актуальному `source_type` — сохранёнными байтами, которые апгрейд уже
записал. Без этого шага повторный приём `.torrent` не помогает: новая загрузка
усыновит ту же зависшую раздачу (см. «усыновить» выше).

Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
загрузку в `catched` для повторной попытки на следующем тике; переход в
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
естественными тиками поллинга, отсечка — `catch_timeout`).

Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией,
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
параллельной отмене).

Вывод имени (LLM) занимает секунды и идёт вне блокировки, поэтому загрузку могут
отменить (`catched → cancelled`) в это окно. Чтобы отменённая задача не оставила
неуправляемый торрент в qBittorrent, worker SHALL применять комбинированную
защиту. Порядок шагов относительно блокировки переходов: `[под блокировкой]`
re-read состояния → `[вне блокировки]` свежий листинг присутствия → `[вне
блокировки]` `add` → `[под блокировкой]` запись перехода и (при неуспехе) re-read
состояния для решения об уборке → `[вне блокировки]` удаление. Сетевые вызовы
(листинг, `add`, удаление) под блокировкой держаться SHALL NOT.

- **Re-read состояния перед `add`.** Непосредственно перед `qbt.Add` (после
  вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если
  она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик
  пропустить. Это сужает окно гонки до промежутка между re-read и записью
  перехода. Этот re-read проверяет **только состояние** и запрос на добавление не
  пересобирает — за `source_type` отвечает более ранний re-read после
  тик-снимка (см. выше).

- **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add`
  worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с
  одним из infohash загрузки ещё НЕТ. Если этот листинг **не удался** (сетевой
  сбой), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить (повтор
  на следующем): без подтверждённого отсутствия признак «своё/чужое» неизвестен,
  и последующее удаление-с-данными было бы небезопасным. Если торрент уже
  присутствует (внешний клиент/пользователь добавил тот же infohash в окно
  гонки), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить — на
  следующем тике её усыновит ветка «уже присутствует». Подтверждённое отсутствие
  непосредственно-перед-`add` SHALL служить признаком того, что торрент,
  оказавшийся под этим infohash сразу после `add`, создан именно этим `add` (наш
  артефакт), а не пред-существовал.

- **Уборка добавленного торрента при отмене в окне после `add`.** Если `add`
  прошёл успешно, а последующая запись перехода `PromoteCatched` не применилась,
  worker SHALL принимать решение об уборке по **свежему re-read состояния под
  блокировкой**, а не по факту ошибки промоушена: неуспех промоушена бывает и
  из-за отмены (`state` уже не `catched`), и из-за транзиентного сбоя хранилища
  (`state` всё ещё `catched`, задача жива). Только при подтверждённом `state !=
  catched` worker SHALL удалить только что добавленный торрент из qBittorrent
  **вместе с его данными** (`deleteFiles = true`) по infohash загрузки. Если
  повторное чтение показало `state == catched` (транзиентный сбой) либо само не
  удалось, worker торрент удалять SHALL NOT — переход доводится на следующем тике
  усыновлением присутствующей (нашей) раздачи. Удаление SHALL идти через API
  qBittorrent (`torrents/delete`), не прямыми fs-операциями. Это легитимная уборка
  **собственного** артефакта, а не пользовательских данных: инвариант «источник
  неприкосновенен» защищает существующие раздачи/файлы пользователя под
  `paths.downloads`, а здесь удаляется торрент, который сам worker добавил
  секундами ранее — уже после намерения отмены. Состояние отменённой задачи
  (`cancelled`) уборка трогать SHALL NOT; неуспех удаления SHALL логироваться
  (торрент временно остаётся, повторная авто-уборка не требуется).

- **Негативный инвариант (удаляем только своё).** Удаление-с-данными допустимо
  ТОЛЬКО для торрента, который worker создал именно этим `add`. Торрент, который
  присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал тот же
  infohash / внешний торрент с тем же хешем), удалять с данными worker SHALL NOT —
  иначе снёс бы чужие данные в нарушение инварианта. Гарантию обеспечивает
  подтверждение отсутствия перед `add`: путь уборки достижим только тогда, когда
  отсутствие infohash было подтверждено непосредственно перед `add`; при
  обнаруженном присутствии (или недоступном листинге) `add` не выполняется вовсе.

Записи об этом пути (торрент оставлен после отмены → удаляем; факт удаления) worker
SHALL логировать на уровне `WARN` с корреляцией по `download_id`/`infohash` и без
секретов; неуспех удаления — на `ERROR`.

#### 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: Апгрейд magnet → .torrent до подготовки запроса — добавляется файлом

- **GIVEN** загрузка попала в снимок списка `catched` с `source_type = magnet`
- **WHEN** апгрейд до `.torrent` применяется после снятия снимка, но до re-read
  записи под блокировкой, и worker обрабатывает эту загрузку
- **THEN** worker берёт `source_type = torrent` из перечитанной записи и
  добавляет раздачу сохранёнными байтами файлом (`torrents`)
- **AND** magnet-ссылка из устаревшего снимка не используется

#### Scenario: Апгрейд magnet → .torrent в окне вывода имени — добавляется magnet

- **GIVEN** загрузка в `catched` с `source_type = magnet`, запрос на добавление
  подготовлен, worker выводит отображаемое имя вне блокировки
- **WHEN** апгрейд до `.torrent` применяется именно в это окно
- **THEN** worker добавляет magnet-ссылку из подготовленного запроса — апгрейд в
  этом окне не подхватывается (известное ограничение)
- **AND** на закрытом трекере раздача зависает в `metaDL` и по `magnet_timeout`
  задача уходит в `failed`
- **AND** восстановление требует ручного шага: убрать зависшую раздачу из
  qBittorrent, после чего `Retry` добавляет источник заново по актуальному
  `source_type` — сохранёнными байтами

#### Scenario: Байты .torrent недоступны — повтор

- **GIVEN** загрузка в `catched` с `source_type = torrent`, торрента в
  qBittorrent нет, но сохранённые байты `.torrent` прочитать не удалось
- **WHEN** worker готовит запрос на добавление
- **THEN** worker `add` НЕ вызывает
- **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике
  (отсечка — `catch_timeout`)

#### 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: Свежий листинг перед add недоступен — повтор

- **GIVEN** загрузка в `catched`, торрента в снимке тика нет, имя выведено
- **WHEN** свежий листинг присутствия непосредственно перед `add` не удался
  (сетевой сбой)
- **THEN** worker `add` НЕ вызывает (отсутствие infohash не подтверждено)
- **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике

#### Scenario: Отмена до add — источник не добавляется

- **GIVEN** загрузка в `catched`, worker выводит отображаемое имя вне блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`) во время
  вывода имени, а затем worker перечитывает состояние перед `add`
- **THEN** re-read видит, что загрузка уже не в `catched`, и `qbt.Add` НЕ
  вызывается
- **AND** источник в qBittorrent не добавляется, задача остаётся `cancelled`

#### Scenario: Отмена в окне после add — добавленный торрент удаляется с данными

- **GIVEN** загрузка в `catched`, отсутствие её infohash в qBittorrent
  подтверждено перед `add`, и `add` прошёл успешно
- **WHEN** отмена (`catched → cancelled`) приходит в окне между `add` и записью
  перехода, из-за чего запись перехода не применяется, а re-read состояния под
  блокировкой показывает `state != catched`
- **THEN** worker удаляет только что добавленный торрент из qBittorrent вместе с
  его данными (`deleteFiles = true`) по infohash загрузки
- **AND** пишет `WARN` о том, что торрент оставлен после отмены и удалён
- **AND** состояние задачи остаётся `cancelled`

#### Scenario: Сбой записи перехода без отмены — торрент не удаляется

- **GIVEN** загрузка в `catched`, `add` прошёл успешно, но запись перехода
  `PromoteCatched` вернула ошибку из-за транзиентного сбоя хранилища
- **WHEN** re-read состояния под блокировкой показывает, что загрузка всё ещё в
  `catched` (отмены не было)
- **THEN** worker торрент из qBittorrent НЕ удаляет (это наш живой торрент)
- **AND** переход доводится на следующем тике усыновлением присутствующей раздачи

#### Scenario: Пред-существующий торрент не удаляется с данными

- **GIVEN** загрузка в `catched`, чей infohash уже присутствует в qBittorrent к
  моменту проверки перед `add` (внешний торрент/раздача пользователя с тем же
  хешем)
- **WHEN** worker обрабатывает тик и параллельно приходит отмена
- **THEN** worker `add` НЕ выполняет и торрент с данными НЕ удаляет (чужие данные
  неприкосновенны)
- **AND** загрузка пропускается в этот тик (усыновление присутствующей раздачи —
  на следующем тике, если задача ещё активна)

### 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`

