Приём: усыновление присутствующего в 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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-10
|
||||
@@ -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
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,57 @@
|
||||
## Why
|
||||
|
||||
Пойманная (`catched`) загрузка, чей торрент **уже присутствует в qBittorrent**
|
||||
(добавлен раньше вручную/другим клиентом или прошлой попыткой jellybit), уходит в
|
||||
бесконечный цикл: `processCatched` на каждом тике зовёт `qbt.Add`, qBittorrent
|
||||
отбивает дубль (`409 Conflict`), сбой трактуется как транзиентный → задача
|
||||
остаётся в `catched` → повтор, и на каждом безнадёжном тике впустую вызывается
|
||||
LLM-namer. Диагноз: `docs/backlog/review-f2-promote-without-add.md`.
|
||||
|
||||
Решение: перед добавлением проверять присутствие торрента в qBittorrent. Раз
|
||||
инвариант приёма гарантирует, что до воркера доходит лишь загрузка, для которой в
|
||||
jellybit нет другой активной задачи (дубль на активную отсекается ещё на приёме),
|
||||
присутствие торрента в qBittorrent означает «его надо **усыновить**» — довести до
|
||||
`downloading` без повторного `add` и разложить, а не пытаться добавить дубль и
|
||||
ловить 409.
|
||||
|
||||
Отдельно: повторное добавление торрента, который jellybit **уже ведёт активной
|
||||
задачей**, транспорт должен явно отражать как дубль (сообщение «добавление
|
||||
отменено»), а не молчаливым «уже в работе».
|
||||
|
||||
## What Changes
|
||||
|
||||
- В `processCatched` перед `qbt.Add` — **проверка присутствия торрента в
|
||||
qBittorrent** (один листинг на тик). Присутствует → `catched → downloading`
|
||||
**без `add`** (усыновление; `display_name` из имени раздачи, без LLM); нет →
|
||||
прежний путь добавления. Проверка — **до namer**, чтобы не жечь LLM.
|
||||
- При недоступности qBittorrent (листинг не удался) тик пропускается без вызова
|
||||
LLM; предел ретрая — существующий предохранитель `catch_timeout`.
|
||||
- Гонка апгрейда F6 сужается: `source_type` перечитывается под блокировкой
|
||||
переходов перед добавлением.
|
||||
- Транспорт Telegram на дедуп приёма (дубль на уже активную задачу) отвечает
|
||||
явным «дубль уже активной #id — добавление отменено» (+ лог), без создания
|
||||
новой записи.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
<!-- нет новых capability -->
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `download-tracking`: требование «Добавление пойманной загрузки в qBittorrent»
|
||||
дополняется проверкой присутствия и усыновлением (promote без повторного
|
||||
`add`) при наличии торрента, перечитыванием источника под замком и пропуском
|
||||
тика при недоступности qBittorrent.
|
||||
|
||||
## Impact
|
||||
|
||||
- Код: `internal/worker/worker.go` (`processCatched`, presence-check,
|
||||
усыновление вместо повторного `add`), `internal/tgbot/bot.go` (текст ответа на
|
||||
дедуп).
|
||||
- Внешние вызовы: убирает лишние `qbt.Add` (и 409) и `chat.completions`
|
||||
(LLM-namer) на повторах; добавляет один `qbt.Torrents`-листинг на тик в
|
||||
`processCatched`.
|
||||
- Тесты: `internal/worker/catched_test.go`.
|
||||
- **БД-миграции, новых состояний, конфигурации и внешнего API — нет.**
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
## 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` (иначе переход отклоняется — например, при
|
||||
параллельной отмене).
|
||||
|
||||
#### 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: Отмена во время добавления
|
||||
|
||||
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
|
||||
блокировки
|
||||
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
|
||||
worker берёт блокировку для записи перехода
|
||||
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
|
||||
`downloading` не применяется
|
||||
@@ -0,0 +1,37 @@
|
||||
## 1. Усыновление в processCatched
|
||||
|
||||
- [x] 1.1 В начале `processCatched` один раз получить листинг `qbt.Torrents("")`
|
||||
и построить `byHash` (по `Hash`/`InfohashV1`/`InfohashV2`, lowercase); провал
|
||||
листинга → WARN и ранний выход (пойманные не трогаем этот тик, namer не зовём).
|
||||
- [x] 1.2 Для каждой catched-задачи: `torrentFor(d, byHash)`. Присутствует →
|
||||
усыновление БЕЗ namer/Add: под замком с ре-валидацией `state='catched'`
|
||||
`PromoteCatched(id, t.Name)` (`catched → downloading`, имя из раздачи снимка).
|
||||
- [x] 1.3 Отсутствует в `byHash` — прежний путь, но с re-read записи под `w.mu`
|
||||
перед сбором `addReq` (свежий `source_type`, ре-валидация `state='catched'`);
|
||||
тяжёлые вызовы (`GetTorrentData`, namer, `qbt.Add`) — вне замка.
|
||||
- [x] 1.4 Убедиться, что namer и `qbt.Add` не вызываются на ветке присутствия и
|
||||
при провале листинга.
|
||||
|
||||
## 2. Сообщение о дубле на приёме
|
||||
|
||||
- [x] 2.1 В `internal/tgbot/bot.go` (`ingestAndReply`) на `res.Deduplicated`
|
||||
отвечать явным «♻️ Дубль уже активной загрузки #id — добавление отменено»
|
||||
(вместо «Уже в работе #id»). Лог дедупа в `ingest` уже есть.
|
||||
|
||||
## 3. Тесты
|
||||
|
||||
- [x] 3.1 `catched_test.go`: торрент присутствует в снимке qBittorrent →
|
||||
`downloading` без `Add` и без namer; `display_name` = имя раздачи. (Фейк qBit
|
||||
отдаёт торрент в снимке ДО обработки задачи.)
|
||||
- [x] 3.2 `catched_test.go`: листинг qBittorrent провалился → задача осталась
|
||||
`catched`, namer/Add не вызывались.
|
||||
- [x] 3.3 `catched_test.go`: торрента нет в снимке → обычный путь (namer + Add +
|
||||
promote) остаётся зелёным; re-read `source_type` под замком берёт актуальный тип.
|
||||
- [x] 3.4 Регресс: catch_timeout-предохранитель, отмена во время добавления.
|
||||
|
||||
## 4. Проверки и ревью
|
||||
|
||||
- [x] 4.1 `task test` и `task lint` зелёные.
|
||||
- [x] 4.2 Ревью кода (чекпоинт перед archive): jellybit-review-code +
|
||||
jellybit-review-specs (сверка со спекой download-tracking).
|
||||
- [x] 4.3 `openspec validate --strict catched-promote-without-readd`.
|
||||
Reference in New Issue
Block a user