Files
jellybit/openspec/changes/archive/2026-07-10-catched-promote-without-readd/design.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

7.0 KiB
Raw Blame History

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 → sourceAddPartsAddPromoteCatched. 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

Нет.