diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 0b062b0..991421d 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -35,7 +35,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог - [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать… - [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку - [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags -- [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_ ## Низкий diff --git a/docs/backlog/review-f3-cancel-during-add.md b/docs/backlog/review-f3-cancel-during-add.md deleted file mode 100644 index 8080a53..0000000 --- a/docs/backlog/review-f3-cancel-during-add.md +++ /dev/null @@ -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 (нужно решение по инварианту «источник неприкосновенен»). diff --git a/internal/worker/catched_test.go b/internal/worker/catched_test.go index 7bf58e6..ad177cc 100644 --- a/internal/worker/catched_test.go +++ b/internal/worker/catched_test.go @@ -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) } } diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 6a76724..b6e5df2 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -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") + } } } diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 4ae9a1c..b6e63b0 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -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 } diff --git a/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/.openspec.yaml b/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/.openspec.yaml new file mode 100644 index 0000000..ff5f854 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-17 diff --git a/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/design.md b/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/design.md new file mode 100644 index 0000000..b213336 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/design.md @@ -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: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 + +Нет. diff --git a/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/proposal.md b/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/proposal.md new file mode 100644 index 0000000..ac0bd21 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/proposal.md @@ -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`. diff --git a/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/specs/download-tracking/spec.md b/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/specs/download-tracking/spec.md new file mode 100644 index 0000000..e806a52 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/specs/download-tracking/spec.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** загрузка пропускается в этот тик (усыновление присутствующей раздачи — + на следующем тике, если задача ещё активна) diff --git a/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/tasks.md b/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/tasks.md new file mode 100644 index 0000000..d225328 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-cancel-during-add-cleanup/tasks.md @@ -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` diff --git a/openspec/specs/download-tracking/spec.md b/openspec/specs/download-tracking/spec.md index 9538928..e18a6b8 100644 --- a/openspec/specs/download-tracking/spec.md +++ b/openspec/specs/download-tracking/spec.md @@ -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