Приём: усыновление присутствующего в 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>
This commit is contained in:
av
2026-07-10 18:36:47 +03:00
co-authored by Claude Opus 4.8
parent 0c9421f4c1
commit b8657120fe
13 changed files with 544 additions and 47 deletions
@@ -0,0 +1,87 @@
## Context
`processCatched` (`internal/worker/worker.go`) вызывается из `pollOnce` сразу
после `Poll`. Сейчас он безусловно зовёт namer (LLM) и `qbt.Add`, а `409`/`Fails.`
на дубле трактует как транзиентный сбой → вечный повтор с тратой LLM.
Приём (`ingest`) уже дедуплицирует по infohash на **активную** задачу
(`FindActiveByInfohash`/`CreateDownloadIfNoActive`). Значит до `processCatched`
доходит только загрузка, для которой в jellybit нет другой активной задачи.
Отсюда упрощение: если торрент такой загрузки уже присутствует в qBittorrent,
это не «конфликт с чужой задачей», а «раздачу уже кто-то (пользователь вручную,
прошлый тик) добавил» — надо просто **усыновить** её и разложить.
## Goals / Non-Goals
**Goals:**
- Пойманная загрузка, чей торрент уже в qBittorrent, доводится до `downloading`
без повторного `add` (без 409) и без LLM; дальше — обычная раскладка.
- LLM-namer не вызывается ни при усыновлении, ни при недоступности qBittorrent.
- Гонка апгрейда F6 сужена перечитыванием `source_type` под блокировкой.
- Повторное добавление уже активной в jellybit загрузки транспорт отражает как
дубль (сообщение + лог), без новой записи.
**Non-Goals:**
- Не заводим новых состояний загрузки. Дедуп на приёме записи не создаёт;
усыновление — это `downloading`, а не отдельный статус.
- Не различаем «наш/чужой» торрент по категории/тегу: инвариант приёма делает
различие ненужным (до воркера доходит лишь загрузка без другой активной).
- Не добавляем счётчик попыток `add` (предел — время `catch_timeout`).
- Не проверяем присутствие на приёме (`ingest` остаётся быстрым, без qBittorrent).
## Decisions
**1. Источник снимка присутствия: один листинг `qbt.Torrents("")` на входе в
`processCatched`.** Строим `byHash` (по `Hash`/`InfohashV1`/`InfohashV2`,
lowercase), переиспользуем для всех catched-задач тика (как это делает Poll для
своего снимка). Провал листинга → qBittorrent недоступен → в этот тик пойманные
не трогаем (namer не зовём), повтор на следующем; отсечка — `catch_timeout`.
Поиск торрента задачи — существующий `torrentFor(d, byHash)`.
**2. Присутствует → усыновляем; ветвление до namer.** Если `torrentFor` нашёл
раздачу — `PromoteCatched(id, t.Name)` (перевод `catched → downloading` + имя из
`qbt.Torrent.Name`, без LLM), под коротким замком с ре-валидацией `state='catched'`.
Иначе — обычный путь: re-read под замком → namer → `sourceAddParts``Add`
`PromoteCatched`. namer (LLM) на ветке усыновления и при недоступности qBit не
зовётся.
**3. Никакого различия «наш/чужой» и никакого `duplicated`.** Инвариант приёма
(«одна активная на infohash») гарантирует, что усыновляемая раздача не отберётся
у другой активной задачи. `PromoteCatched` (гард `state='catched'`) корректен;
`ActivateIfNoOtherActive` (как в `Retry` из терминального `failed`) здесь не
нужен — `catched` нетерминален и уже единственный активный владелец infohash.
Имя раздачи в `display_name` полезно и уведомлениям, и заголовку в UI (у `catched`
оно пусто).
**4. Re-read `source_type` под замком перед добавлением (сужение гонки F6).**
На absent-ветке перед сбором `addReq` берём короткий замок, перечитываем запись
(`GetDownload`): ре-валидация `state='catched'` и актуальный `source_type`
(апгрейд magnet→torrent мог случиться после снятия списка). Тяжёлые вызовы
(`GetTorrentData`, namer, `Add`) — вне замка.
**5. Дедуп на приёме (case 1) — только сообщение.** `ingest` при попадании на
активную задачу уже возвращает `Deduplicated=true` (запись не создаётся). Меняем
лишь текст ответа транспорта: вместо «Уже в работе #id» — «♻️ дубль уже активной
#id, добавление отменено». Лог дедупа (`download attached to active`) уже есть.
Новых состояний/записей не заводим.
## Risks / Trade-offs
- **[Остаточное окно F6]** → re-read `source_type` под замком + `Add` вне замка
окно резко **сужают**, но не закрывают полностью. Полное закрытие требует
держать замок через `Add`, что нарушает инвариант «тяжёлые вызовы вне замка».
Оверлап крайне редок, цена промаха — один неудачный magnet-add, повтор на
следующем тике уже увидит `torrent`. Принимаем суженное окно осознанно.
- **[Снимок присутствия на тик «отстаёт»]** → каждая catched-задача
обрабатывается раз за тик; если наш `add` прошёл, а запись перехода сорвалась,
усыновление случится на следующем тике, где листинг уже видит раздачу.
- **[Усыновление раздачи, добавленной вручную с иными savepath/категорией]** →
инвариант источника не нарушается: файлы не наши, раскладка хардлинчит
отдельно; infohash совпадает — контент тот же. Осознанное поведение (как
`discover`).
## Open Questions
Нет.