## Context `processCatched` (`internal/worker/worker.go`) добавляет пойманные загрузки в qBittorrent. Медленные вызовы (namer/LLM, `qbt.Add`) идут **вне** блокировки переходов `w.mu`, под замком — только короткие DB-переходы. Последовательность на «обычном» пути добавления (торрента в снимке тика нет): 1. под `w.mu` re-read записи → проверка `state == catched`, актуализация `source_type` (текущий код: worker.go:447–453); 2. вне замка: `namer.Derive` (секунды) → `qbt.Add`; 3. под `w.mu`: `PromoteCatched` (гард `state='catched'`) → `catched → downloading`. Гонка F3: отмена (`catched → cancelled`) приходит между шагом 1 и шагом 3. Гард `PromoteCatched` честно отклоняет переход (`state` уже `cancelled`), но `qbt.Add` на шаге 2 уже отработал — торрент добавлен под нашей категорией и остался в qBittorrent без задачи-владельца. `adopt` его назад не подхватит: `ExistsByInfohash` истинно (хеши принадлежат отменённой записи). Диск занят, владельца нет. ## Goals / Non-Goals **Goals** - Не добавлять источник, если отмена видна ещё до `add`. - Убрать добавленный нами торрент (с данными), если отмена случилась в окне после `add`. - Никогда не удалять с данными торрент, которого мы не создавали этим `add`. **Non-Goals** - Не меняем FSM-граф и семантику `PromoteCatched`/`cancel`. - Не вводим новых состояний, полей БД, методов `qbt`/`store`. - Не закрываем окно гонки полностью (у qBittorrent нет транзакции «add+own»); сужаем его и гарантированно убираем последствия. - **Не в scope: апгрейд `source_type` во время namer.** `source_type`/`addReq` worker перечитывает перед namer (текущий код), а не в D1-re-read перед `add`. Пред-существующий зазор «magnet→torrent апгрейд случился во время вывода имени» этой задачей не закрывается и не ухудшается (D1 читает только состояние). D1 специально держим дешёвым (только `state`) и не тянем чтение байтов торрента под `w.mu`; закрытие зазора — отдельная задача. ## Decisions ### D1. Re-read состояния прямо перед `qbt.Add` После `namer.Derive` (медленный шаг) и **непосредственно перед** `qbt.Add` worker берёт `w.mu`, перечитывает запись и проверяет `state == catched`. Если уже не `catched` (отменена во время namer) — `add` не делаем, задачу пропускаем. Это переносит основную защиту на самый частый сценарий: окно namer (секунды) куда шире окна между `add` и записью перехода (один сетевой вызов). Re-read под `w.mu` перечитывает только **состояние** (дёшево, локальный SQLite). Актуализацию `source_type`/`addReq` он НЕ повторяет — см. «Границы блокировки» и «Не в scope» ниже. ### D2. Проверка отсутствия infohash перед `add` — признак «своего» торрента Сразу перед `add` (после D1, но **вне** `w.mu` — это сетевой вызов) worker свежим листингом qBittorrent подтверждает, что раздачи с любым из infohash загрузки **ещё нет**. Реализуется переиспользованием `qbt.Torrents(ctx, "")` (тот же механизм, что снимок тика) — нового API не нужно. - Если листинг **не удался** (сеть отвалилась) — worker `add` НЕ делает и задачу в этот тик пропускает (повтор на следующем). Это критично: без подтверждённого отсутствия «своё/чужое» неразличимо, и delete-with-data стал бы небезопасен. Поведение то же, что уже принято для листинга тика (сбой → не трогаем). - Если infohash **уже присутствует** (внешний клиент/пользователь добавил тот же торрент в окно гонки после снимка тика) — worker `add` НЕ делает и задачу пропускает: на следующем тике её штатно усыновит `promoteExisting` (ветка «уже присутствует»). Чужие данные не трогаются. - Если infohash **отсутствует** — только тогда делаем `add`. Тем самым любой торрент, оказавшийся под этим infohash сразу после нашего `add`, — **наш** артефакт. Именно подтверждённое отсутствие-перед-`add` — механизм различения «своё/чужое». Он не завязан на семантику ответа `qbt.Add` (qBittorrent на дубль отвечает тем же `Ok.`, не сообщая, создал он раздачу или это был дубль). ### D3. Scoped cleanup при отмене в окне после `add` Если `add` прошёл (D2 подтвердил отсутствие), а затем `PromoteCatched` не применил переход, worker принимает решение об уборке **по свежему re-read состояния под `w.mu`**, а не по тексту/факту ошибки `PromoteCatched`. Причина: `PromoteCatched` возвращает ошибку в двух разных случаях — (1) гард `n==0` (`state` действительно уже не `catched`, отмена) и (2) транзиентный сбой БД (`state` всё ещё `catched`, задача жива). Вешать delete-with-data на «любую ошибку промоушена» нельзя: при миге БД это снесло бы **свой же, но ещё активный** торрент. Поэтому под тем же `w.mu` worker перечитывает запись: - `state != catched` (подтверждённая отмена) → удаляем добавленный торрент из qBittorrent **с данными**: `qbt.Delete(ctx, hashes, deleteFiles=true)` по infohash загрузки. `Delete` идемпотентен (неизвестный хеш qBittorrent игнорирует). Сбой удаления — `ERROR`-лог, состояние задачи (`cancelled`) не трогаем. - `state == catched` (транзиентный сбой БД) или re-read сам упал → торрент НЕ удаляем, `WARN` «promote failed, will retry»: на следующем тике `promoteExisting` усыновит присутствующую (нашу же) раздачу — переход доведётся, ничего не потеряно. Cleanup достижим ТОЛЬКО по пути, где D2 подтвердил отсутствие infohash перед `add`, — поэтому удаляемый торрент гарантированно создан этим `add`. ### Границы блокировки (сериализация переходов) Порядок вокруг `add` соблюдает инвариант «медленные/сетевые вызовы вне `w.mu`»: 1. `[под w.mu]` re-read состояния (D1); 2. `[вне w.mu]` свежий листинг присутствия (D2) — сетевой вызов; 3. `[вне w.mu]` `qbt.Add`; 4. `[под w.mu]` `PromoteCatched` + (при неуспехе) re-read состояния для решения об уборке (D3); 5. `[вне w.mu]` `qbt.Delete` при подтверждённой отмене. Между шагами 1–3 остаётся окно (описанный TOCTOU), но `add` защищён свежим подтверждением отсутствия (шаг 2), а любая пропажа гарантии деградирует к безопасному «не удаляем» (D3). ### D4. Логи - `WARN "torrent left in qbittorrent after cancel, removing"` — вход в cleanup, поле-причина промаха `PromoteCatched`. - `WARN "added torrent removed after cancel"` — факт успешного удаления. - `ERROR` — если `qbt.Delete` не удался (мусор остался, нужен разбор). - D1/D2-пропуски — `INFO` (штатная развилка, не проблема). Все записи несут `download_id`/`infohash` (scoped-логгер `cctx`), без секретов. Сам вызов `qbt.Delete`/`Torrents` логирует клиент (`ext.*`). ## Инвариант «источник неприкосновенен» и негативная гарантия **Артикуляция решения.** Инвариант «источник неприкосновенен» (CLAUDE.md, architecture.md, ADR-hardlinks) защищает **пользовательские данные** под `paths.downloads`/существующие раздачи от НАШИХ прямых fs-операций (`unlink`/`rename`). Торрент, который jellybit добавил секундами ранее — уже после намерения отмены, — это **наш собственный артефакт**, а не пользовательские данные. Его удаление через API qBittorrent (не прямыми fs-операциями) — легитимная уборка своего мусора. Прецедент уже есть: команда «Удалить» (`delete`) осознанно сносит раздачу с данными через `qbt.Delete(..., true)` (state-reconciliation) — там обход инварианта санкционирован пользователем; здесь удаляется лишь то, что мы сами только что создали вопреки уже выраженной отмене. **Негативная гарантия (КРИТИЧНО).** Удаление-с-данными недопустимо для торрента, который присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал тот же infohash / внешний торрент с тем же хешем). Удалить его с данными означало бы снести чужие данные — прямое нарушение инварианта. Защита — D2: удаление достижимо только на пути, где отсутствие infohash подтверждено непосредственно перед `add`; при обнаруженном присутствии `add` не делается вовсе, а торрент уходит на усыновление. Так удаляется исключительно созданное нами этим `add`. **Остаточное окно (честно).** Между проверкой присутствия (D2) и самим `add` остаётся микроскопический TOCTOU-зазор: внешний клиент теоретически мог добавить тот же infohash в этот промежуток (два последовательных сетевых вызова). У qBittorrent нет атомарного «create-or-fail по infohash» и `add` не сообщает, создал он раздачу или присоединился к дублю, — устранить зазор имеющимся API нельзя. Мы сознательно выбираем `add` только при подтверждённом отсутствии непосредственно перед ним (зазор на порядки меньше окна namer из D1) и считаем это санкцией на уборку. Дальнейшее сужение (напр. сверка `added_on` раздачи со временем нашего `add`) — возможное усиление на будущее, в этой задаче не делаем: базис по времени хрупок (скос часов, грубое разрешение), а вероятность внешнего добавления ровно в этот под-`add` зазор пренебрежимо мала. ## Risks / Trade-offs - **Лишний листинг qBittorrent перед каждым `add`** пойманной загрузки. Добавления событийны и редки (не поллинг), стоимость незначительна; переиспользуем существующий `Torrents`. - **Cleanup зависит от доступности qBittorrent.** Если `qbt.Delete` не прошёл — торрент временно остаётся, но это уже отменённая задача; `ERROR`-лог фиксирует для разбора. Повторной авто-уборки не вводим (не усложняем): случай редкий. ## Migration Plan Изменение чисто поведенческое в `worker.processCatched`. Схема БД, конфиг, API `qbt`/`store` не меняются. Откат — возврат прежней ветки добавления. ## Open Questions Нет.