Жизненный цикл: уборка торрента при отмене во время добавления (F3/NIT-13)

Отмена задачи (catched→cancelled) в окно, пока worker вне блокировки выводит
имя (LLM) и делает qbt.Add, оставляла добавленный торрент в qBittorrent без
задачи-владельца: PromoteCatched корректно пропускал переход, но источник уже
качался/сидировал вечно, а усыновить его назад нельзя (хеши принадлежат
отменённой задаче). Спека покрывала переход состояния, но не побочный эффект.

Комбинированная защита в processCatched:
- re-read состояния под w.mu прямо перед qbt.Add — при отмене источник не
  добавляется вовсе (сужает окно гонки);
- свежий листинг перед add подтверждает отсутствие infohash — признак «своего»
  торрента; при сбое листинга/присутствии add не делаем (усыновит следующий тик);
- при отмене в окне после add (промах PromoteCatched, подтверждённый re-read'ом
  state != catched) — уборка добавленного нами торрента qbt.Delete(_, true);
- WARN/ERROR-логи по этому пути с корреляцией по download_id, без секретов.

Гарантия «удаляем только своё»: удаление-с-данными достижимо ТОЛЬКО после
подтверждённого отсутствия infohash перед add, поэтому пред-существующий/чужой
торрент с тем же хешем никогда не сносится (негативный инвариант). Обоснование
по инварианту «источник неприкосновенен» — в design.md изменения.

Дельта — download-tracking (требование «Добавление пойманной загрузки в
qBittorrent»): re-read перед add, подтверждение отсутствия, уборка при отмене,
негативный сценарий. Тесты покрывают все ветки (skip-before-add, cleanup после
add, пред-существующий не удаляется, сбой БД не удаляет, сбой листинга не
добавляет).

Change archived: 2026-07-17-cancel-during-add-cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-17 22:10:21 +03:00
co-authored by Claude Opus 4.8
parent 0354a8c96b
commit 3a00fde058
11 changed files with 792 additions and 30 deletions
-1
View File
@@ -38,7 +38,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
- [Defer из catched → лимбо → необратимый deleted (MAJOR-6)](review-major6-defer-catched.md) — Defer из ещё-не-добавленного catched уводит задачу в необратимый deleted _(ревью 2026-07-08)_
- [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_
## Низкий
@@ -1,11 +0,0 @@
# Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (оба ревьюера: F3 + NIT-13). worker.go:368-390, discover.go:43-50.
Сценарий: задача catched, worker вне w.mu выводит имя (LLM, секунды) + qbt.Add (успех). Параллельно user Cancel (catched→cancelled). PromoteCatched корректно пропускает (гард state='catched', спека соблюдена). НО торрент ДОБАВЛЕН в qBit под нашей категорией, будет качаться/сидировать вечно. Усыновить назад нельзя: adopt через ExistsByInfohash (любое состояние) → хеши cancelled-задачи существуют. Торрент ест диск без видимой задачи и владельца. Спека покрывает переход состояния, но не побочный эффект. Инвариант «источник неприкосновенен» — но этот торрент добавили МЫ после cancel-намерения.
Фикс-опции: (a) re-read state прямо перед Add (сужает окно); (b) при promote-skip из-за cancel — WARN «torrent left in qBittorrent»; (c) scoped delete/pause только что добавленного нами.
Вердикт: change (нужно решение по инварианту «источник неприкосновенен»).
+118 -6
View File
@@ -214,9 +214,10 @@ func TestProcessCatchedTimeoutFails(t *testing.T) {
}
}
// Ре-валидация: если во время сетевых вызовов (вне блокировки) задачу отменили,
// переход в downloading не применяется — состояние остаётся cancelled.
func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
// F3: отмена во время (медленного) вывода имени видна re-read'ом состояния ПЕРЕД
// Add — источник в qBittorrent не добавляется вовсе (раньше Add успевал пройти,
// оставляя неуправляемый торрент без владельца).
func TestProcessCatchedCancelledDuringNamerSkipsAdd(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
@@ -226,11 +227,122 @@ func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatal("add должен был вызваться (сеть идёт вне замка)")
if len(qb.added) != 0 {
t.Errorf("Add не должен вызываться после отмены (re-read перед add), calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("торрент не добавляли — удалять нечего, Delete calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("ре-валидация не сработала: state = %q, want cancelled", st.downloads["1"].State)
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
}
}
// F3, scoped cleanup: отмена приходит в окне ПОСЛЕ успешного Add, но до записи
// перехода (onAdd имитирует Cancel ровно между Add и PromoteCatched). Добавленный
// НАМИ торрент удаляется из qBittorrent С ДАННЫМИ (уборка своего артефакта).
func TestProcessCatchedCancelledAfterAddRemovesTorrent(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
qb.onAdd = func() { st.downloads["1"].State = store.StateCancelled }
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatalf("Add должен был вызваться, calls = %d", len(qb.added))
}
if len(qb.deleted) != 1 {
t.Fatalf("добавленный нами торрент должен быть удалён, Delete calls = %d", len(qb.deleted))
}
if !qb.deletedData[0] {
t.Error("уборка своего артефакта должна идти С ДАННЫМИ (deleteFiles=true)")
}
if len(qb.deleted[0]) == 0 || qb.deleted[0][0] != catchedIH {
t.Errorf("удаление не по infohash загрузки: %v", qb.deleted[0])
}
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled (уборка не трогает состояние)", st.downloads["1"].State)
}
}
// F3, негативный инвариант «удаляем только своё»: тот же infohash появился в
// qBittorrent во время namer (внешний клиент, окно гонки). Свежий листинг перед
// Add видит присутствие → Add не делаем И чужой торрент С ДАННЫМИ не удаляем.
func TestProcessCatchedPreexistingTorrentNotDeleted(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{} // снимок тика пуст → идём обычным путём добавления
w := newTestWorker(st, qb)
nm := &fakeNamer{name: "X", onCall: func() {
// внешний клиент добавил тот же торрент, пока выводилось имя
qb.torrents = []qbt.Torrent{{Hash: catchedIH, Name: "external"}}
}}
w.SetNamer(nm)
w.processCatched(context.Background())
if len(qb.added) != 0 {
t.Errorf("Add не должен вызываться: infohash уже присутствует перед add, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("пред-существующий (чужой) торрент удалять нельзя, Delete calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (усыновление на следующем тике)", st.downloads["1"].State)
}
}
// F3, safety-critical: свежий листинг присутствия ПЕРЕД add не удался (сеть
// отвалилась между тик-снимком и проверкой). Отсутствие infohash не подтверждено
// → Add не делаем (иначе delete-с-данными стал бы небезопасен), остаёмся в
// catched. onTorrents роняет ВТОРОЙ вызов Torrents (первый — тик-снимок).
func TestProcessCatchedPresenceRecheckFailKeepsCatched(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
calls := 0
qb.onTorrents = func() {
calls++
if calls == 2 { // тик-снимок (1) ок, свежий листинг перед add (2) падает
qb.torrentsErr = errors.New("connection refused")
}
}
w := newTestWorker(st, qb)
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 0 {
t.Errorf("Add не должен вызываться при неподтверждённом отсутствии, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("Delete не должен вызываться, calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (повтор на следующем тике)", st.downloads["1"].State)
}
}
// F3, различение «отмена vs сбой БД»: PromoteCatched упал транзиентно, но задача
// ЖИВА (state остался catched). Наш торрент НЕ удаляем — переход доведётся на
// следующем тике усыновлением присутствующей раздачи.
func TestProcessCatchedPromoteDBErrorKeepsTorrent(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
st.promoteErr = errors.New("db is locked") // сбой записи перехода, state = catched
qb := &fakeQbt{}
w := newTestWorker(st, qb)
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatalf("Add должен был вызваться, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("при транзиентном сбое БД (задача жива) торрент удалять нельзя, Delete calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (повтор промоушена на следующем тике)", st.downloads["1"].State)
}
}
+62 -4
View File
@@ -476,6 +476,38 @@ func (w *Worker) processCatched(ctx context.Context) {
}
}
addReq.Rename = rename
// F3: re-read состояния прямо перед Add — вывод имени (LLM) шёл секунды вне
// замка, задачу могли отменить (catched → cancelled). Если уже не catched,
// источник в qBittorrent не добавляем вовсе (иначе остался бы неуправляемый
// торрент без задачи-владельца).
w.mu.Lock()
before, berr := w.store.GetDownload(cctx, d.ID)
stillCatched := berr == nil && before != nil && before.State == store.StateCatched
w.mu.Unlock()
if !stillCatched {
logctx.From(cctx).Info("catched add skipped before qbittorrent", "reason", "no longer catched")
continue
}
// F3, гарантия «удаляем только своё»: свежим листингом (вне замка)
// подтверждаем, что раздачи с нашим infohash в qBittorrent ЕЩЁ НЕТ. Только
// тогда торрент, появившийся под этим хешем сразу после нашего Add, — наш
// артефакт, и позднейшая уборка вправе снести его С ДАННЫМИ. Сбой листинга →
// отсутствие не подтверждено, Add не делаем (повтор на следующем тике),
// иначе delete-с-данными стал бы небезопасен. Присутствие → внешний клиент
// добавил тот же торрент в окно гонки: Add не делаем, усыновит следующий тик
// (promoteExisting); чужие данные не трогаем.
snap, ferr := w.qbt.Torrents(cctx, "")
if ferr != nil {
logctx.From(cctx).Warn("catched presence recheck failed, will retry", "error", ferr)
continue
}
if _, present := torrentFor(*before, torrentsByHash(snap)); present {
logctx.From(cctx).Info("catched torrent already present in qbittorrent, will adopt")
continue
}
addErr := w.qbt.Add(cctx, addReq)
if addErr != nil {
// Транзиентный сбой (qBit отверг/недоступен) — остаёмся в catched,
@@ -484,16 +516,42 @@ func (w *Worker) processCatched(ctx context.Context) {
continue
}
// Успех: короткий переход под w.mu с ре-валидацией state=catched
// Успех Add: короткий переход под w.mu с ре-валидацией state=catched
// (загрузку могли отменить, пока шли сетевые вызовы).
w.mu.Lock()
if err := w.store.PromoteCatched(cctx, d.ID, rename); err != nil {
logctx.From(cctx).Info("catched promote skipped", "reason", err.Error())
} else {
perr := w.store.PromoteCatched(cctx, d.ID, rename)
if perr == nil {
logctx.From(cctx).Info("state transition", "from", store.StateCatched,
"to", store.StateDownloading)
w.mu.Unlock()
continue
}
// Промоут не прошёл. Причину определяем СВЕЖИМ состоянием под тем же замком,
// а НЕ текстом ошибки (см. errors.md): PromoteCatched возвращает ошибку и при
// отмене (state != catched), и при транзиентном сбое БД (state всё ещё
// catched, задача жива).
after, aerr := w.store.GetDownload(cctx, d.ID)
cancelled := aerr == nil && after != nil && after.State != store.StateCatched
w.mu.Unlock()
if !cancelled {
// Транзиентный сбой БД (или не смогли перечитать) — торрент наш и живой,
// не удаляем: переход доведётся на следующем тике усыновлением
// присутствующей раздачи (promoteExisting).
logctx.From(cctx).Warn("catched promote failed, will retry", "error", perr)
continue
}
// F3: отмена (catched → cancelled) в окне между Add и записью перехода.
// Торрент добавлен НАМИ этим Add (отсутствие infohash подтверждено выше), а
// задачи-владельца больше нет — снимаем свой артефакт С ДАННЫМИ. Инвариант
// «источник неприкосновенен» защищает пользовательские данные, а не наш
// только что добавленный торрент; удаление идёт через API qBittorrent, не
// прямыми fs-операциями.
logctx.From(cctx).Warn("torrent left in qbittorrent after cancel, removing", "error", perr)
if delErr := w.qbt.Delete(cctx, before.HashList(), true); delErr != nil {
logctx.From(cctx).Error("cleanup added torrent after cancel failed", "error", delErr)
} else {
logctx.From(cctx).Warn("added torrent removed after cancel")
}
}
}
+11 -1
View File
@@ -24,6 +24,7 @@ type fakeStore struct {
downloads map[string]*store.Download
transitions []transition
torrents map[string][]byte // download_id → байты .torrent
promoteErr error // если задан — PromoteCatched возвращает его, НЕ меняя state (симуляция транзиентного сбоя БД)
}
type transition struct {
@@ -182,6 +183,9 @@ func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) er
if !ok {
return fmt.Errorf("download %s not found", id)
}
if f.promoteErr != nil {
return f.promoteErr // транзиентный сбой БД: state НЕ меняем (остаётся catched)
}
if d.State != store.StateCatched {
return fmt.Errorf("promote catched %s: not in catched (%s)", id, d.State)
}
@@ -281,8 +285,10 @@ type fakeQbt struct {
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
added []qbt.AddRequest
addErr error
onAdd func() // вклинивается в момент Add (симуляция отмены в окне после add)
files []qbt.File
deleted [][]string // хеши каждого вызова Delete
deletedData []bool // deleteFiles каждого вызова Delete (параллельно deleted)
deleteErr error
renamed []renameCall // каждый вызов RenameTorrent (hash, name)
renameErr error
@@ -317,6 +323,9 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
}
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
if f.onAdd != nil {
f.onAdd()
}
if f.addErr != nil {
return f.addErr
}
@@ -328,11 +337,12 @@ func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) {
return f.files, nil
}
func (f *fakeQbt) Delete(_ context.Context, hashes []string, _ bool) error {
func (f *fakeQbt) Delete(_ context.Context, hashes []string, deleteFiles bool) error {
if f.deleteErr != nil {
return f.deleteErr
}
f.deleted = append(f.deleted, hashes)
f.deletedData = append(f.deletedData, deleteFiles)
return nil
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-17
@@ -0,0 +1,178 @@
## 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:447453);
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
Нет.
@@ -0,0 +1,56 @@
## Why
На шаге добавления пойманной загрузки (`catched`) worker выводит имя (LLM,
секунды) и вызывает `qbt.Add` **вне** блокировки переходов. Если в это окно
пользователь отменяет задачу (`catched → cancelled`), запись перехода
`PromoteCatched` корректно пропускается (гард `state='catched'`), НО источник
уже добавлен в qBittorrent под нашей категорией. Такой торрент качается/сидирует
вечно, ест диск, а видимой задачи-владельца нет: усыновить назад его нельзя —
`adopt` проверяет `ExistsByInfohash` (любое состояние), а хеши уже принадлежат
отменённой задаче. Спека покрывает переход состояния, но не этот побочный эффект
(находка ревью F3/NIT-13).
## What Changes
- **Re-read состояния прямо перед `qbt.Add`** (под блокировкой переходов, после
медленного вывода имени): если задача уже не в `catched` (отменена) — источник
в qBittorrent НЕ добавляется вовсе. Сужает окно гонки до промежутка между
re-read и записью перехода.
- **Scoped cleanup**: если отмена случилась в оставшемся окне (уже ПОСЛЕ
успешного `add`, но до записи перехода), worker удаляет только что добавленный
торрент из qBittorrent **вместе с данными** — уборка собственного мусора.
- **Гарантия «удаляем только своё»**: удаление-с-данными допустимо ТОЛЬКО для
торрента, который worker создал именно этим `add`. Признак — подтверждённое
**отсутствие** infohash в qBittorrent непосредственно перед `add`. Если
infohash уже присутствовал до нашего `add` (внешний клиент раздаёт тот же
торрент), worker источник не добавляет и чужие данные не трогает.
- **WARN-логи** на этом пути (торрент оставлен после отмены → удаляем; факт
удаления), с корреляцией по `download_id`, без секретов.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `download-tracking`: требование «Добавление пойманной загрузки в qBittorrent» —
добавляется re-read состояния перед `add`, подтверждение отсутствия infohash
перед `add` как признак «своего» торрента и уборка добавленного торрента при
отмене в окне после `add`. Сценарий «Отмена во время добавления» уточняется и
дополняется сценариями уборки и негативного инварианта.
## Impact
- **Спеки:** дельта `download-tracking` (одно MODIFIED-требование + сценарии).
- **Код:** `internal/worker/worker.go``processCatched` (re-read state и
проверка присутствия перед `Add`; уборка добавленного при промахе
`PromoteCatched`). Новых методов `qbt`/`store` не требуется (переиспользуем
`Torrents`, `Delete`).
- **Тесты:** `internal/worker/catched_test.go` — обновление сценария отмены во
время namer (Add не вызывается) + новые: уборка после Add, негативный инвариант
(пред-существующий торрент не удаляется с данными).
- **Миграции БД:** нет.
- **Инвариант «источник неприкосновенен»:** обоснование удаления-с-данными и
негативная гарантия — в `design.md`.
@@ -0,0 +1,214 @@
## 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` (иначе переход отклоняется — например, при
параллельной отмене).
Вывод имени (LLM) занимает секунды и идёт вне блокировки, поэтому загрузку могут
отменить (`catched → cancelled`) в это окно. Чтобы отменённая задача не оставила
неуправляемый торрент в qBittorrent, worker SHALL применять комбинированную
защиту. Порядок шагов относительно блокировки переходов: `[под блокировкой]`
re-read состояния → `[вне блокировки]` свежий листинг присутствия → `[вне
блокировки]` `add``[под блокировкой]` запись перехода и (при неуспехе) re-read
состояния для решения об уборке → `[вне блокировки]` удаление. Сетевые вызовы
(листинг, `add`, удаление) под блокировкой держаться SHALL NOT.
- **Re-read состояния перед `add`.** Непосредственно перед `qbt.Add` (после
вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если
она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик
пропустить. Это сужает окно гонки до промежутка между re-read и записью
перехода.
- **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add`
worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с
одним из infohash загрузки ещё НЕТ. Если этот листинг **не удался** (сетевой
сбой), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить (повтор
на следующем): без подтверждённого отсутствия признак «своё/чужое» неизвестен,
и последующее удаление-с-данными было бы небезопасным. Если торрент уже
присутствует (внешний клиент/пользователь добавил тот же infohash в окно
гонки), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить — на
следующем тике её усыновит ветка «уже присутствует». Подтверждённое отсутствие
непосредственно-перед-`add` SHALL служить признаком того, что торрент,
оказавшийся под этим infohash сразу после `add`, создан именно этим `add` (наш
артефакт), а не пред-существовал.
- **Уборка добавленного торрента при отмене в окне после `add`.** Если `add`
прошёл успешно, а последующая запись перехода `PromoteCatched` не применилась,
worker SHALL принимать решение об уборке по **свежему re-read состояния под
блокировкой**, а не по факту ошибки промоушена: неуспех промоушена бывает и
из-за отмены (`state` уже не `catched`), и из-за транзиентного сбоя хранилища
(`state` всё ещё `catched`, задача жива). Только при подтверждённом `state !=
catched` worker SHALL удалить только что добавленный торрент из qBittorrent
**вместе с его данными** (`deleteFiles = true`) по infohash загрузки. Если
повторное чтение показало `state == catched` (транзиентный сбой) либо само не
удалось, worker торрент удалять SHALL NOT — переход доводится на следующем тике
усыновлением присутствующей (нашей) раздачи. Удаление SHALL идти через API
qBittorrent (`torrents/delete`), не прямыми fs-операциями. Это легитимная уборка
**собственного** артефакта, а не пользовательских данных: инвариант «источник
неприкосновенен» защищает существующие раздачи/файлы пользователя под
`paths.downloads`, а здесь удаляется торрент, который сам worker добавил
секундами ранее — уже после намерения отмены. Состояние отменённой задачи
(`cancelled`) уборка трогать SHALL NOT; неуспех удаления SHALL логироваться
(торрент временно остаётся, повторная авто-уборка не требуется).
- **Негативный инвариант (удаляем только своё).** Удаление-с-данными допустимо
ТОЛЬКО для торрента, который worker создал именно этим `add`. Торрент, который
присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал тот же
infohash / внешний торрент с тем же хешем), удалять с данными worker SHALL NOT —
иначе снёс бы чужие данные в нарушение инварианта. Гарантию обеспечивает
подтверждение отсутствия перед `add`: путь уборки достижим только тогда, когда
отсутствие infohash было подтверждено непосредственно перед `add`; при
обнаруженном присутствии (или недоступном листинге) `add` не выполняется вовсе.
Записи об этом пути (торрент оставлен после отмены → удаляем; факт удаления) worker
SHALL логировать на уровне `WARN` с корреляцией по `download_id`/`infohash` и без
секретов; неуспех удаления — на `ERROR`.
#### 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: Свежий листинг перед add недоступен — повтор
- **GIVEN** загрузка в `catched`, торрента в снимке тика нет, имя выведено
- **WHEN** свежий листинг присутствия непосредственно перед `add` не удался
(сетевой сбой)
- **THEN** worker `add` НЕ вызывает (отсутствие infohash не подтверждено)
- **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике
#### Scenario: Отмена до add — источник не добавляется
- **GIVEN** загрузка в `catched`, worker выводит отображаемое имя вне блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`) во время
вывода имени, а затем worker перечитывает состояние перед `add`
- **THEN** re-read видит, что загрузка уже не в `catched`, и `qbt.Add` НЕ
вызывается
- **AND** источник в qBittorrent не добавляется, задача остаётся `cancelled`
#### Scenario: Отмена в окне после add — добавленный торрент удаляется с данными
- **GIVEN** загрузка в `catched`, отсутствие её infohash в qBittorrent
подтверждено перед `add`, и `add` прошёл успешно
- **WHEN** отмена (`catched → cancelled`) приходит в окне между `add` и записью
перехода, из-за чего запись перехода не применяется, а re-read состояния под
блокировкой показывает `state != catched`
- **THEN** worker удаляет только что добавленный торрент из qBittorrent вместе с
его данными (`deleteFiles = true`) по infohash загрузки
- **AND** пишет `WARN` о том, что торрент оставлен после отмены и удалён
- **AND** состояние задачи остаётся `cancelled`
#### Scenario: Сбой записи перехода без отмены — торрент не удаляется
- **GIVEN** загрузка в `catched`, `add` прошёл успешно, но запись перехода
`PromoteCatched` вернула ошибку из-за транзиентного сбоя хранилища
- **WHEN** re-read состояния под блокировкой показывает, что загрузка всё ещё в
`catched` (отмены не было)
- **THEN** worker торрент из qBittorrent НЕ удаляет (это наш живой торрент)
- **AND** переход доводится на следующем тике усыновлением присутствующей раздачи
#### Scenario: Пред-существующий торрент не удаляется с данными
- **GIVEN** загрузка в `catched`, чей infohash уже присутствует в qBittorrent к
моменту проверки перед `add` (внешний торрент/раздача пользователя с тем же
хешем)
- **WHEN** worker обрабатывает тик и параллельно приходит отмена
- **THEN** worker `add` НЕ выполняет и торрент с данными НЕ удаляет (чужие данные
неприкосновенны)
- **AND** загрузка пропускается в этот тик (усыновление присутствующей раздачи —
на следующем тике, если задача ещё активна)
@@ -0,0 +1,44 @@
## 1. Код
- [x] 1.1 В `processCatched` (`internal/worker/worker.go`), на обычном пути
добавления, ПОСЛЕ вывода имени и НЕПОСРЕДСТВЕННО перед `qbt.Add`: под `w.mu`
перечитать запись и, если `state != catched`, `add` не делать и загрузку
пропустить (D1)
- [x] 1.2 Там же (вне `w.mu`) добавить свежий листинг присутствия любого из
infohash загрузки в qBittorrent перед `add` (переиспользовать `qbt.Torrents`);
при присутствии — `add` не делать, пропустить (усыновит следующий тик); при
СБОЕ листинга — `add` не делать, `WARN`, повтор на следующем тике (D2, Б1)
- [x] 1.3 При неуспехе `PromoteCatched` после успешного `add` — под `w.mu`
перечитать состояние: только если `state != catched` (подтверждённая отмена) —
удалить добавленный торрент с данными `qbt.Delete(ctx, HashList, true)`; `WARN`
об оставленном/удалённом торренте; `ERROR` при сбое удаления. Если `state ==
catched` (транзиентный сбой БД) или re-read упал — НЕ удалять, `WARN`
«promote failed, will retry» (D3, Б2). Решение по состоянию, не по тексту
ошибки (см. errors.md)
- [x] 1.4 Убедиться, что путь уборки достижим только после подтверждённого
отсутствия перед `add` (негативный инвариант «удаляем только своё»)
## 2. Тесты
- [x] 2.1 Обновить `TestProcessCatchedCancelledDuringAddSkipsPromote`: отмена во
время namer → `qbt.Add` НЕ вызывается (re-read перед add), состояние остаётся
`cancelled` (было: Add вызывался)
- [x] 2.2 Новый тест: отмена в окне ПОСЛЕ `add` (hook на `Add`, ставящий
`cancelled`) → `qbt.Delete` вызван с `deleteFiles=true` по infohash загрузки,
состояние `cancelled`
- [x] 2.3 Новый тест (негативный инвариант): infohash появился в qBittorrent во
время namer (hook на namer) → перед `add` листинг видит присутствие → `qbt.Add`
НЕ вызывается, `qbt.Delete` НЕ вызывается (чужие данные не трогаем)
- [x] 2.4 Новый тест (Б2): сбой `PromoteCatched` при `state == catched`
(транзиентная ошибка БД, без отмены) → торрент НЕ удаляется (`qbt.Delete` не
вызван)
- [x] 2.6 Новый тест (Б1, safety-critical): свежий листинг перед `add` упал
(второй вызов `Torrents`) → `Add`/`Delete` НЕ вызваны, остаётся `catched`
- [x] 2.5 Регрессия: штатный успех (нет отмены) по-прежнему добавляет и
промоутит (`TestProcessCatchedAddsToQbit`); усыновление присутствующего и сбой
листинга тика (`TestProcessCatchedListErrorKeepsCatched`) — без изменений
## 3. Спека
- [x] 3.1 MODIFIED-требование «Добавление пойманной загрузки в qBittorrent» в
`download-tracking`; `openspec validate cancel-during-add-cleanup --strict`
+107 -7
View File
@@ -154,6 +154,66 @@ downloading` (см. «Переходы состояний сериализуют
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
параллельной отмене).
Вывод имени (LLM) занимает секунды и идёт вне блокировки, поэтому загрузку могут
отменить (`catched → cancelled`) в это окно. Чтобы отменённая задача не оставила
неуправляемый торрент в qBittorrent, worker SHALL применять комбинированную
защиту. Порядок шагов относительно блокировки переходов: `[под блокировкой]`
re-read состояния → `[вне блокировки]` свежий листинг присутствия → `[вне
блокировки]` `add``[под блокировкой]` запись перехода и (при неуспехе) re-read
состояния для решения об уборке → `[вне блокировки]` удаление. Сетевые вызовы
(листинг, `add`, удаление) под блокировкой держаться SHALL NOT.
- **Re-read состояния перед `add`.** Непосредственно перед `qbt.Add` (после
вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если
она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик
пропустить. Это сужает окно гонки до промежутка между re-read и записью
перехода.
- **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add`
worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с
одним из infohash загрузки ещё НЕТ. Если этот листинг **не удался** (сетевой
сбой), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить (повтор
на следующем): без подтверждённого отсутствия признак «своё/чужое» неизвестен,
и последующее удаление-с-данными было бы небезопасным. Если торрент уже
присутствует (внешний клиент/пользователь добавил тот же infohash в окно
гонки), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить — на
следующем тике её усыновит ветка «уже присутствует». Подтверждённое отсутствие
непосредственно-перед-`add` SHALL служить признаком того, что торрент,
оказавшийся под этим infohash сразу после `add`, создан именно этим `add` (наш
артефакт), а не пред-существовал.
- **Уборка добавленного торрента при отмене в окне после `add`.** Если `add`
прошёл успешно, а последующая запись перехода `PromoteCatched` не применилась,
worker SHALL принимать решение об уборке по **свежему re-read состояния под
блокировкой**, а не по факту ошибки промоушена: неуспех промоушена бывает и
из-за отмены (`state` уже не `catched`), и из-за транзиентного сбоя хранилища
(`state` всё ещё `catched`, задача жива). Только при подтверждённом `state !=
catched` worker SHALL удалить только что добавленный торрент из qBittorrent
**вместе с его данными** (`deleteFiles = true`) по infohash загрузки. Если
повторное чтение показало `state == catched` (транзиентный сбой) либо само не
удалось, worker торрент удалять SHALL NOT — переход доводится на следующем тике
усыновлением присутствующей (нашей) раздачи. Удаление SHALL идти через API
qBittorrent (`torrents/delete`), не прямыми fs-операциями. Это легитимная уборка
**собственного** артефакта, а не пользовательских данных: инвариант «источник
неприкосновенен» защищает существующие раздачи/файлы пользователя под
`paths.downloads`, а здесь удаляется торрент, который сам worker добавил
секундами ранее — уже после намерения отмены. Состояние отменённой задачи
(`cancelled`) уборка трогать SHALL NOT; неуспех удаления SHALL логироваться
(торрент временно остаётся, повторная авто-уборка не требуется).
- **Негативный инвариант (удаляем только своё).** Удаление-с-данными допустимо
ТОЛЬКО для торрента, который worker создал именно этим `add`. Торрент, который
присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал тот же
infohash / внешний торрент с тем же хешем), удалять с данными worker SHALL NOT —
иначе снёс бы чужие данные в нарушение инварианта. Гарантию обеспечивает
подтверждение отсутствия перед `add`: путь уборки достижим только тогда, когда
отсутствие infohash было подтверждено непосредственно перед `add`; при
обнаруженном присутствии (или недоступном листинге) `add` не выполняется вовсе.
Записи об этом пути (торрент оставлен после отмены → удаляем; факт удаления) worker
SHALL логировать на уровне `WARN` с корреляцией по `download_id`/`infohash` и без
секретов; неуспех удаления — на `ERROR`.
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
@@ -196,14 +256,54 @@ downloading` (см. «Переходы состояний сериализуют
- **THEN** загрузка остаётся в `catched`
- **AND** на следующем тике попытка добавления повторяется
#### Scenario: Отмена во время добавления
#### Scenario: Свежий листинг перед add недоступен — повтор
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
worker берёт блокировку для записи перехода
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
`downloading` не применяется
- **GIVEN** загрузка в `catched`, торрента в снимке тика нет, имя выведено
- **WHEN** свежий листинг присутствия непосредственно перед `add` не удался
(сетевой сбой)
- **THEN** worker `add` НЕ вызывает (отсутствие infohash не подтверждено)
- **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике
#### Scenario: Отмена до add — источник не добавляется
- **GIVEN** загрузка в `catched`, worker выводит отображаемое имя вне блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`) во время
вывода имени, а затем worker перечитывает состояние перед `add`
- **THEN** re-read видит, что загрузка уже не в `catched`, и `qbt.Add` НЕ
вызывается
- **AND** источник в qBittorrent не добавляется, задача остаётся `cancelled`
#### Scenario: Отмена в окне после add — добавленный торрент удаляется с данными
- **GIVEN** загрузка в `catched`, отсутствие её infohash в qBittorrent
подтверждено перед `add`, и `add` прошёл успешно
- **WHEN** отмена (`catched → cancelled`) приходит в окне между `add` и записью
перехода, из-за чего запись перехода не применяется, а re-read состояния под
блокировкой показывает `state != catched`
- **THEN** worker удаляет только что добавленный торрент из qBittorrent вместе с
его данными (`deleteFiles = true`) по infohash загрузки
- **AND** пишет `WARN` о том, что торрент оставлен после отмены и удалён
- **AND** состояние задачи остаётся `cancelled`
#### Scenario: Сбой записи перехода без отмены — торрент не удаляется
- **GIVEN** загрузка в `catched`, `add` прошёл успешно, но запись перехода
`PromoteCatched` вернула ошибку из-за транзиентного сбоя хранилища
- **WHEN** re-read состояния под блокировкой показывает, что загрузка всё ещё в
`catched` (отмены не было)
- **THEN** worker торрент из qBittorrent НЕ удаляет (это наш живой торрент)
- **AND** переход доводится на следующем тике усыновлением присутствующей раздачи
#### Scenario: Пред-существующий торрент не удаляется с данными
- **GIVEN** загрузка в `catched`, чей infohash уже присутствует в qBittorrent к
моменту проверки перед `add` (внешний торрент/раздача пользователя с тем же
хешем)
- **WHEN** worker обрабатывает тик и параллельно приходит отмена
- **THEN** worker `add` НЕ выполняет и торрент с данными НЕ удаляет (чужие данные
неприкосновенны)
- **AND** загрузка пропускается в этот тик (усыновление присутствующей раздачи —
на следующем тике, если задача ещё активна)
### Requirement: Предохранитель зависшего catched