Жизненный цикл: уборка торрента при отмене во время добавления (F3/NIT-13)

Отмена задачи (catched→cancelled) в окно, пока worker вне блокировки выводит
имя (LLM) и делает qbt.Add, оставляла добавленный торрент в qBittorrent без
задачи-владельца: PromoteCatched корректно пропускал переход, но источник уже
качался/сидировал вечно, а усыновить его назад нельзя (хеши принадлежат
отменённой задаче). Спека покрывала переход состояния, но не побочный эффект.

Комбинированная защита в processCatched:
- re-read состояния под w.mu прямо перед qbt.Add — при отмене источник не
  добавляется вовсе (сужает окно гонки);
- свежий листинг перед add подтверждает отсутствие infohash — признак «своего»
  торрента; при сбое листинга/присутствии add не делаем (усыновит следующий тик);
- при отмене в окне после add (промах PromoteCatched, подтверждённый re-read'ом
  state != catched) — уборка добавленного нами торрента qbt.Delete(_, true);
- WARN/ERROR-логи по этому пути с корреляцией по download_id, без секретов.

Гарантия «удаляем только своё»: удаление-с-данными достижимо ТОЛЬКО после
подтверждённого отсутствия infohash перед add, поэтому пред-существующий/чужой
торрент с тем же хешем никогда не сносится (негативный инвариант). Обоснование
по инварианту «источник неприкосновенен» — в design.md изменения.

Дельта — download-tracking (требование «Добавление пойманной загрузки в
qBittorrent»): re-read перед add, подтверждение отсутствия, уборка при отмене,
негативный сценарий. Тесты покрывают все ветки (skip-before-add, cleanup после
add, пред-существующий не удаляется, сбой БД не удаляет, сбой листинга не
добавляет).

Change archived: 2026-07-17-cancel-during-add-cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-17 22:10:21 +03:00
co-authored by Claude Opus 4.8
parent 0354a8c96b
commit 3a00fde058
11 changed files with 792 additions and 30 deletions
@@ -0,0 +1,214 @@
## MODIFIED Requirements
### 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` (иначе переход отклоняется — например, при
параллельной отмене).
Вывод имени (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 и записью
перехода.
- **Подтверждение отсутствия торрента перед `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: Торрент уже присутствует в 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** загрузка пропускается в этот тик (усыновление присутствующей раздачи —
на следующем тике, если задача ещё активна)