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>
88 lines
7.0 KiB
Markdown
88 lines
7.0 KiB
Markdown
## 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
|
||
|
||
Нет.
|