diff --git a/internal/worker/discover.go b/internal/worker/discover.go index c46574a..e9e7438 100644 --- a/internal/worker/discover.go +++ b/internal/worker/discover.go @@ -110,6 +110,30 @@ func torrentHashes(t qbt.Torrent) []string { return out } +// torrentIndexHashes — хеши, под которыми раздачу индексируют lookup-мапы +// (byHash/live) и поиск по infohash. Зеркалит выбор torrentHashes: t.Hash берём +// только когда qBittorrent не отдал infohash_v1/v2 (старый API). У v2-only +// раздачи t.Hash — усечённый до 40 hex v2-хеш; индексировать его нельзя — по длине +// он неотличим от v1 и мог бы ложно сматчить задачу с таким же v1-хешем (NIT-11), +// хотя для этого нужна невозможная на практике 160-битная коллизия. В отличие от +// torrentHashes хеши не нормализует и не дедуплицирует — ключ приводит вызывающий. +func torrentIndexHashes(t qbt.Torrent) []string { + if t.InfohashV1 == "" && t.InfohashV2 == "" { + if t.Hash == "" { + return nil + } + return []string{t.Hash} + } + out := make([]string, 0, 2) + if t.InfohashV1 != "" { + out = append(out, t.InfohashV1) + } + if t.InfohashV2 != "" { + out = append(out, t.InfohashV2) + } + return out +} + // magnetURN — синтетический источник усыновлённой раздачи по её хешу: // btih для v1, btmh (multihash sha256, префикс 1220) для v2. Хеш обязан // быть полноразмерным (torrentHashes усечённые не отдаёт). diff --git a/internal/worker/discover_test.go b/internal/worker/discover_test.go index dbd4e14..298f150 100644 --- a/internal/worker/discover_test.go +++ b/internal/worker/discover_test.go @@ -180,6 +180,28 @@ func TestTorrentHashes(t *testing.T) { } } +// TestTorrentIndexHashes — NIT-11: lookup-мапы индексируют раздачу по её хешам, +// но усечённый 40-hex t.Hash v2-only торрента исключаем (по длине неотличим от v1). +func TestTorrentIndexHashes(t *testing.T) { + // Гибрид: индексируем оба infohash, t.Hash игнорируем. + got := torrentIndexHashes(qbt.Torrent{Hash: "trunc", InfohashV1: "v1", InfohashV2: "v2"}) + if len(got) != 2 || got[0] != "v1" || got[1] != "v2" { + t.Errorf("гибрид: got %v, want [v1 v2] (t.Hash исключён)", got) + } + // Старый qBittorrent без infohash_v1/v2 — берём hash (это полный v1). + if got := torrentIndexHashes(qbt.Torrent{Hash: "legacy"}); len(got) != 1 || got[0] != "legacy" { + t.Errorf("legacy: got %v, want [legacy]", got) + } + // v2-only: усечённый t.Hash не индексируем, только полный v2. + const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + if got := torrentIndexHashes(qbt.Torrent{Hash: v2[:40], InfohashV2: v2}); len(got) != 1 || got[0] != v2 { + t.Errorf("v2-only: got %v, want [%s] (усечённый t.Hash исключён)", got, v2) + } + if got := torrentIndexHashes(qbt.Torrent{}); len(got) != 0 { + t.Errorf("пустой: got %v, want empty", got) + } +} + // Усыновление v2-only раздачи: SourceRef — валидный btmh-magnet из полного // v2-хеша (не битый btih из усечённого), kind в БД — v2. func TestDiscover_AdoptsV2Only(t *testing.T) { diff --git a/internal/worker/live_test.go b/internal/worker/live_test.go index fcae258..4d1dc7d 100644 --- a/internal/worker/live_test.go +++ b/internal/worker/live_test.go @@ -9,15 +9,16 @@ import ( ) // TestPollBuildsLiveSnapshot: после Poll снимок несёт телеметрию качающейся и -// сидирующей задач (с верным Seeding), доступную по любому из трёх хэшей; +// сидирующей задач (с верным Seeding), доступную по любому из infohash; // неизвестный/пустой infohash → ok=false. func TestPollBuildsLiveSnapshot(t *testing.T) { qb := &fakeQbt{torrents: []qbt.Torrent{ // Качается (Category пуст → discover не усыновляет, store не мешает). {Hash: "aaa", State: "downloading", Progress: 0.5, Dlspeed: 1000, Eta: 120, TotalSize: 4096}, - // Сидирует, торрент v2 (три ключа). + // Сидирует, гибридный торрент: t.Hash дублирует v1 (как отдаёт qBittorrent), + // индексируем по infohash_v1/v2 (усечённый t.Hash не индексируем — NIT-11). { - Hash: "bbb", InfohashV1: "bbb1", InfohashV2: "BBB2", + Hash: "bbb1", InfohashV1: "bbb1", InfohashV2: "BBB2", State: "uploading", Progress: 1.0, TotalSize: 8192, Ratio: 2.5, NumSeeds: 3, NumLeechs: 1, Uploaded: 999, Upspeed: 50, }, @@ -42,8 +43,8 @@ func TestPollBuildsLiveSnapshot(t *testing.T) { t.Errorf("полный размер качающейся задачи: got %d, want 4096", dl.TotalSize) } - // Сидирующая задача находится по любому из трёх хэшей (lowercase). - for _, h := range []string{"bbb", "bbb1", "BBB2", "bbb2"} { + // Сидирующая задача находится по любому из своих infohash (lowercase). + for _, h := range []string{"bbb1", "BBB2", "bbb2"} { sd, ok := w.Live(h) if !ok { t.Fatalf("нет телеметрии раздачи по ключу %q", h) diff --git a/internal/worker/review.go b/internal/worker/review.go index a8a8188..d5c2ee0 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -69,7 +69,18 @@ func (w *Worker) recognizeOne(ctx context.Context, id string) { ctx = w.scoped(ctx, capRecognize, id, d.PrimaryInfohash()) if d.State == store.StateCompleted { w.transition(ctx, *d, store.StateRecognizing, "", "") + // Перечитываем ради свежего updated_at: он служит claim-токеном этой + // попытки (MINOR-8). Токен фиксирует конкретный recognizing-эпизод; если + // задачу позже уведут из recognizing и вернут обратно (cancel → relink), + // updated_at сменится, и finishRecognition отбросит устаревший результат. + d, err = w.store.GetDownload(ctx, id) + if err != nil { + w.mu.Unlock() + logctx.From(ctx).Warn("recognition reload after claim failed", "error", err) + return + } } + claim := d.UpdatedAt w.mu.Unlock() result, savePath, err := w.runRecognize(ctx, *d) @@ -81,7 +92,7 @@ func (w *Worker) recognizeOne(ctx context.Context, id string) { Reasons: []string{"распознавание не удалось: " + err.Error()}, }} } - w.finishRecognition(ctx, id, result, savePath) + w.finishRecognition(ctx, id, claim, result, savePath) } // runRecognize собирает сигналы из qBittorrent и накопленные подсказки, @@ -127,7 +138,7 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize. // finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3 // метабазы выключены → авто-раскладки не делаем, всегда уходим в review. -func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize.Result, savePath string) { +func (w *Worker) finishRecognition(ctx context.Context, id, claim string, res recognize.Result, savePath string) { log := logctx.From(ctx) planJSON, err := json.Marshal(res.Plan) if err != nil { @@ -169,7 +180,23 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize } if d.State != store.StateRecognizing { // За время вызова LLM задачу увели (cancel/defer) — результат не нужен. - log.Info("recognition result discarded", "state", d.State) + log.Info("recognition result discarded", "reason", "state_changed", "state", d.State) + return + } + // Claim-токен (MINOR-8): состояние снова recognizing, но за время вызова LLM + // задачу могли увести из recognizing и вернуть обратно (cancel → relink revive). + // Тогда это УЖЕ другой recognizing-эпизод (updated_at сменился), а наш результат + // принадлежит прежней попытке — отбрасываем. Задача остаётся в recognizing, и + // поллинг-цикл (recognizePending) перезапустит распознавание свежей попыткой. + // + // Токен — updated_at секундной точности; сравниваем на строгое равенство, поэтому + // отбрасываем при ЛЮБОй его смене. В recognizing-эпизоде метку бьют только переходы + // состояния (не-переходные мутации задачи в этом состоянии не идут), так что валидный + // результат ложно не теряется; а редкий холостой сброс безопасен — распознавание + // просто повторится. Остаточное окно — revive целиком в ту же секунду, что и исходный + // claim (человеческий темп cancel→relink это исключает). + if d.UpdatedAt != claim { + log.Info("recognition result discarded", "reason", "stale_claim") return } recID, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons) @@ -1405,8 +1432,8 @@ func (w *Worker) torrentByInfohash(ctx context.Context, hashes []string) (qbt.To want[store.NormalizeHash(h)] = true } for _, t := range torrents { - for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} { - if h != "" && want[strings.ToLower(h)] { + for _, h := range torrentIndexHashes(t) { + if want[strings.ToLower(h)] { return t, true, nil } } diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 0689bee..5375485 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -315,6 +315,10 @@ type memStore struct { // Инъекция сбоев (для тестов устойчивости раскладки). failCreateLinks error // CreateFileLinks вернёт эту ошибку failSetState func(store.State) error // SetDownloadState вернёт ошибку для перехода + + // clock — монотонный счётчик для updated_at (как реальный store бьёт метку на + // каждом переходе); питает проверку claim-токена распознавания (MINOR-8). + clock int } func newMemStore() *memStore { @@ -450,6 +454,8 @@ func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State d.State = st d.ErrorCode = store.NullString(code) d.ErrorMsg = store.NullString(msg) + m.clock++ + d.UpdatedAt = itoa(m.clock) // свежая метка перехода — claim-токен (MINOR-8) return nil } @@ -810,6 +816,35 @@ func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) { } } +// TestRecognizeOne_DiscardsStaleClaim — MINOR-8: если во время вызова LLM задачу +// увели из recognizing и вернули обратно (cancel → relink revive), это уже другой +// recognizing-эпизод (updated_at сменился). Устаревший результат прежнего прогона +// отбрасывается, задача остаётся в recognizing для перезапуска поллингом. +func TestRecognizeOne_DiscardsStaleClaim(t *testing.T) { + st := newMemStore() + st.put(completedDownload("1")) + qb := &fakeQbt{ + torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}, + files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}}, + } + // Во время вызова LLM задачу уводят из recognizing и возвращают обратно + // (симуляция cancel → relink): состояние снова recognizing, но updated_at сменился. + rec := &fakeRecognizer{result: seriesResult(), onCall: func() { + _ = st.SetDownloadState(context.Background(), "1", store.StateCancelled, "", "") + _ = st.SetDownloadState(context.Background(), "1", store.StateRecognizing, "", "") + }} + w := testWorkerWith(st, qb, rec, nil) + + w.recognizeOne(context.Background(), "1") + + if st.downloads["1"].State != store.StateRecognizing { + t.Errorf("state = %q, want recognizing (устаревший результат отброшен, ждём перезапуска)", st.downloads["1"].State) + } + if cur, _ := st.GetCurrentRecognition(context.Background(), "1"); cur != nil { + t.Error("recognition устаревшей попытки не должна сохраняться") + } +} + func TestRecognizeOne_SignalsErrorToReview(t *testing.T) { st := newMemStore() st.put(completedDownload("1")) diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 23a2be7..9f84bda 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -524,10 +524,8 @@ func (w *Worker) promoteExisting(ctx context.Context, d store.Download, t qbt.To func torrentsByHash(torrents []qbt.Torrent) map[string]qbt.Torrent { byHash := make(map[string]qbt.Torrent, len(torrents)*2) for _, t := range torrents { - for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} { - if h != "" { - byHash[strings.ToLower(h)] = t - } + for _, h := range torrentIndexHashes(t) { + byHash[strings.ToLower(h)] = t } } return byHash @@ -585,12 +583,10 @@ func (w *Worker) Poll(ctx context.Context) error { live := make(map[string]Live, len(torrents)*2) for _, t := range torrents { l := liveFrom(t) - for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} { - if h != "" { - key := strings.ToLower(h) - byHash[key] = t - live[key] = l - } + for _, h := range torrentIndexHashes(t) { + key := strings.ToLower(h) + byHash[key] = t + live[key] = l } } // Снимок зависит только от torrents — свопаем сразу, до store-операций @@ -964,12 +960,10 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) { return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable: %w", id, d.State, ErrConflict) } // Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней, - // повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add — - // когда источника в qBittorrent нет ИЛИ он в состоянии ошибки: перецепка к - // сломанному торренту (error/missingFiles) бессмысленна — reconcile тут же - // вернул бы задачу в failed, поэтому пробуем повторно отдать источник - // (NIT-12). Базис таймаута сбрасывается ниже через retried_at, поэтому - // возврат в downloading не роняет задачу снова на ближайшем тике (MAJOR-1). + // повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add — когда + // источника в qBittorrent нет. Базис таймаута сбрасывается ниже через + // retried_at, поэтому возврат в downloading не роняет задачу снова на ближайшем + // тике (MAJOR-1). reAdd := true if hashes := d.HashList(); len(hashes) > 0 { var t qbt.Torrent @@ -978,7 +972,15 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) { if err != nil { return fmt.Errorf("retry: %w", err) } - reAdd = !alive || classify(t.State) == classErrored + if alive && classify(t.State) == classErrored { + // Живой, но сломанный торрент (error/missingFiles): повторный Add его не + // чинит (qBittorrent отверг бы дубль), а простой возврат в downloading + // тут же снова упал бы classErrored на ближайшем тике сверки (+ дебаунс + // уведомления) — retry выглядел бы сломанным. Отклоняем с подсказкой + // починить раздачу в qBittorrent (recheck/восстановить файлы) — NIT-12. + return fmt.Errorf("retry: раздача в qBittorrent в состоянии ошибки (%s) — почини её (recheck) в qBittorrent и повтори: %w", t.State, ErrConflict) + } + reAdd = !alive } // Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала // в failed, тем же infohash могла завладеть другая активная задача — тогда diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 2226801..4ae9a1c 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -578,6 +578,55 @@ func TestRetryRejectsActive(t *testing.T) { } } +// TestRetryRejectsLiveErroredTorrent — NIT-12: живой, но сломанный торрент +// (error/missingFiles) при retry не перецепляем и не возвращаем в downloading — +// иначе сверка тут же вернула бы задачу в failed (+дебаунс уведомления), retry +// выглядел бы сломанным. Отклоняем с ErrConflict, состояние не трогаем, повторный +// Add не делаем — пользователю подсказано починить раздачу (recheck) в qBittorrent. +func TestRetryRejectsLiveErroredTorrent(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + for _, state := range []string{"missingFiles", "error"} { + t.Run(state, func(t *testing.T) { + st := oneDownloading(ih, timeRecent) + st.downloads["1"].State = store.StateFailed + st.downloads["1"].ErrorCode = store.NullString("qbit_error") + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: state}}} + w := newTestWorker(st, qb) + + if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) { + t.Fatalf("err = %v, want ErrConflict", err) + } + if len(qb.added) != 0 { + t.Errorf("сломанный живой торрент не переотдаём: %d Add", len(qb.added)) + } + if got := st.downloads["1"].State; got != store.StateFailed { + t.Errorf("state = %s, want failed (не тронуто)", got) + } + }) + } +} + +// TestRetryReattachesLiveHealthyTorrent — контроль к NIT-12: живой ЗДОРОВЫЙ торрент +// перецепляем без повторного Add (регрессия — errored-ветка не должна задеть +// здоровый путь). +func TestRetryReattachesLiveHealthyTorrent(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneDownloading(ih, timeRecent) + st.downloads["1"].State = store.StateStuck + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "stalledUP"}}} + w := newTestWorker(st, qb) + + if err := w.Retry(context.Background(), "1"); err != nil { + t.Fatalf("Retry: %v", err) + } + if len(qb.added) != 0 { + t.Errorf("живой здоровый торрент не переотдаём: %d Add", len(qb.added)) + } + if st.downloads["1"].State != store.StateDownloading { + t.Errorf("state = %q, want downloading", st.downloads["1"].State) + } +} + func TestClassify(t *testing.T) { cases := map[string]class{ "uploading": classReady, diff --git a/openspec/changes/2026-07-17-retry-reject-broken-torrent/proposal.md b/openspec/changes/2026-07-17-retry-reject-broken-torrent/proposal.md new file mode 100644 index 0000000..3c26099 --- /dev/null +++ b/openspec/changes/2026-07-17-retry-reject-broken-torrent/proposal.md @@ -0,0 +1,42 @@ +## Why + +Retry задачи в `failed`/`stuck`, чей торрент ЖИВ в qBittorrent, но в состоянии +ошибки (`error`/`missingFiles`), сейчас повторно отдаёт источник. На живом +сломанном торренте это бесполезно: qBittorrent отвергает дубль, задача уходит в +`downloading`, а на ближайшем тике сверки `classErrored` тут же возвращает её в +`failed` (+ дебаунс уведомления). Пользователю retry выглядит сломанным, а +реальное лекарство (перепроверка/`recheck`/восстановление файлов в qBittorrent) +не подсказано (находка ревью NIT-12). + +## What Changes + +- **Retry сломанного живого торрента отклоняется**, а не переотдаёт источник. + Отказ несёт понятное сообщение: починить раздачу (`recheck`/восстановить + файлы) в qBittorrent и повторить. Состояние задачи не меняется, повторного + `Add` не происходит. +- Повторный `Add` при retry остаётся только для случая, когда раздачи в + qBittorrent НЕТ (перецепка к здоровому живому торренту — без `Add`, как и + раньше). + +## Capabilities + +### New Capabilities + +Нет. + +### Modified Capabilities + +- `state-reconciliation`: требование «Ручной повтор зависшей/упавшей загрузки из + транспортов» — ветка живого сломанного торрента меняет исход с «повторно отдать + источник» на «отклонить с подсказкой про `recheck`». Прочие ветки retry (нет + раздачи → `Add`; жив и здоров → перецепка без `Add`; сброс базиса `retried_at`) + без изменений. + +## Impact + +- **Спеки:** дельта `state-reconciliation` (одно MODIFIED-требование). +- **Код:** `internal/worker/worker.go` — `Retry` (ветка `alive && classErrored` + → отказ `ErrConflict` с сообщением вместо `reAdd`). +- **Тесты:** `internal/worker/worker_test.go` — отклонение retry на живом + сломанном торренте (`error`/`missingFiles`), контроль перецепки здорового. +- **Миграции БД:** нет. diff --git a/openspec/changes/2026-07-17-retry-reject-broken-torrent/specs/state-reconciliation/spec.md b/openspec/changes/2026-07-17-retry-reject-broken-torrent/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..012d232 --- /dev/null +++ b/openspec/changes/2026-07-17-retry-reject-broken-torrent/specs/state-reconciliation/spec.md @@ -0,0 +1,84 @@ +## MODIFIED Requirements + +### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов + +Система SHALL предоставлять пользователю команду повторной попытки (retry) +для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API). +Retry SHALL переводить задачу обратно в `downloading`, не вызывая её +немедленного повторного падения по таймауту: базис отсчёта таймаутов SHALL +сбрасываться. + +Сброс базиса система SHALL выполнять сохранением времени retry в поле задачи +(`retried_at`, RFC 3339 UTC), которое приподнимает пол ОБОИХ страховочных мер +(`magnet_timeout` по возрасту и `stuck_after` по простою): отсчёт ведётся от +`max(базис, retried_at)`. `retried_at` SHALL храниться в задаче (не в памяти +процесса), чтобы сброс базиса пережил интервал поллинга и рестарт процесса. +Благодаря этому даже живой, но давно добавленный либо давно простаивающий +торрент после retry SHALL получать свежее окно и на ближайшем тике сверки +падать снова SHALL NOT. + +Если источник задачи уже жив и ЗДОРОВ в qBittorrent, retry SHALL перецепляться +к существующему торренту, а не добавлять источник повторно вслепую. Если же +живой торрент в состоянии ошибки qBittorrent (`error`/`missingFiles`), retry +SHALL отклоняться с понятным пользователю сообщением — починить раздачу +(`recheck`/восстановить файлы) в qBittorrent и повторить. Повторная отдача +источника такой торрент не чинит (qBittorrent отверг бы дубль), а простой +возврат в `downloading` тут же снова упал бы `qbit_error` по сверке (+ дебаунс +уведомления) — retry выглядел бы сломанным. При отказе состояние задачи +(`failed`/`stuck`) система менять SHALL NOT и повторный `Add` выполнять SHALL NOT. + +Повторный `Add` при retry система SHALL выполнять, только когда раздачи в +qBittorrent нет, — **по типу источника** (`source_type`), как и добавление +пойманной загрузки (см. `download-tracking` «Добавление пойманной загрузки в +qBittorrent»): magnet/url — ссылкой; torrent — сохранёнными байтами `.torrent` +файлом. Для torrent-источника retry БЕЗ живой раздачи система SHALL добавлять +раздачу байтами и SHALL NOT активировать задачу в `downloading`, не добавив её +(иначе задача повиснет как «нет в qBittorrent»). + +#### Scenario: Retry упавшей magnet-загрузки из веб-UI + +- **GIVEN** задача в `failed`, её торрент жив и здоров в qBittorrent +- **WHEN** пользователь нажимает retry в веб-UI +- **THEN** задача возвращается в `downloading` без повторного `Add` +- **AND** не падает снова на ближайшем тике сверки по таймауту + +#### Scenario: Retry живого, но давно простаивающего торрента не падает снова + +- **GIVEN** задача в `stuck`/`stalled`, её торрент жив в qBittorrent, но + добавлен давно и данные не двигались дольше `stuck_after` +- **WHEN** пользователь нажимает retry +- **THEN** задача возвращается в `downloading` без повторного `Add` +- **AND** на ближайшем тике сверки НЕ падает снова в `stuck` (базис сброшен + через `retried_at`) + +#### Scenario: Retry доступен в Telegram + +- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в + Telegram-боте +- **THEN** задача возвращается в `downloading` + +#### Scenario: Retry без живого источника добавляет источник заново + +- **GIVEN** задача в `failed`, раздачи в qBittorrent нет +- **WHEN** пользователь инициирует retry +- **THEN** источник добавляется в qBittorrent заново — magnet/url ссылкой, + torrent сохранёнными байтами файлом +- **AND** задача переходит в `downloading` + +#### Scenario: Retry сломанного живого торрента отклоняется + +- **GIVEN** задача в `failed`, её торрент присутствует в qBittorrent, но в + состоянии ошибки (`error`/`missingFiles`) +- **WHEN** пользователь инициирует retry +- **THEN** retry отклоняется с сообщением починить раздачу (`recheck`) в + qBittorrent +- **AND** состояние задачи не меняется (остаётся `failed`), повторный `Add` не + выполняется + +#### Scenario: Retry torrent-загрузки без живого источника + +- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent + нет, байты `.torrent` сохранены +- **WHEN** пользователь инициирует retry +- **THEN** сохранённые байты добавляются в qBittorrent файлом +- **AND** задача переходит в `downloading` (не остаётся без раздачи) diff --git a/openspec/changes/2026-07-17-retry-reject-broken-torrent/tasks.md b/openspec/changes/2026-07-17-retry-reject-broken-torrent/tasks.md new file mode 100644 index 0000000..ac6c207 --- /dev/null +++ b/openspec/changes/2026-07-17-retry-reject-broken-torrent/tasks.md @@ -0,0 +1,17 @@ +## 1. Код + +- [x] 1.1 В `Worker.Retry` заменить ветку `alive && classify(state)==classErrored`: + вместо `reAdd=true` — отказ `ErrConflict` с понятным сообщением (починить + раздачу `recheck` в qBittorrent), без изменения состояния и без `Add` +- [x] 1.2 `reAdd` оставить `true` только когда раздачи нет (`!alive`) + +## 2. Тесты + +- [x] 2.1 `TestRetryRejectsLiveErroredTorrent` — `error`/`missingFiles`: retry + возвращает `ErrConflict`, состояние `failed` не тронуто, `Add` не вызван +- [x] 2.2 `TestRetryReattachesLiveHealthyTorrent` — контроль: живой здоровый + торрент перецепляется без `Add` + +## 3. Спека + +- [x] 3.1 MODIFIED-требование в `state-reconciliation`; `openspec validate --strict`