Жизненный цикл: claim-токен распознавания, индексация хешей, retry сломанного торрента

Три мелких фикса из docs/backlog/review-lifecycle-minor.md (ревью Fable
2026-07-08). MINOR-9 (I/O под глобальным w.mu) осознанно waive для
one-user home-сервера — не трогаем.

MINOR-8: claim-токен распознавания. recognizeOne фиксирует updated_at на
момент claim (перечитывая запись после перехода в recognizing), а
finishRecognition коммитит результат, только если токен совпал. Иначе за
время LLM-вызова задачу увели из recognizing и вернули обратно
(cancel → relink revive) — это уже другой эпизод, устаревший результат
отбрасываем, задача остаётся в recognizing для перезапуска поллингом.

NIT-11: lookup-мапы (byHash/live/torrentByInfohash) больше не индексируют
усечённый 40-hex t.Hash v2-only торрентов. Новый хелпер torrentIndexHashes
зеркалит выбор torrentHashes: t.Hash берём только при отсутствии обоих
infohash_v1/v2. Убирает теоретический ложный матч по коллизии длины.

NIT-12: retry живого, но сломанного торрента (error/missingFiles) теперь
отклоняется с подсказкой починить раздачу (recheck) в qBittorrent, вместо
бессмысленной переотдачи источника (сверка тут же вернула бы задачу в
failed). Повторный Add — только когда раздачи в qBittorrent нет. Меняет
спеку state-reconciliation → дельта openspec/changes/2026-07-17-retry-reject-broken-torrent
(не архивировал).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-17 20:35:14 +03:00
co-authored by Claude Opus 4.8
parent b8017d65eb
commit bcc7b2d76b
10 changed files with 330 additions and 27 deletions
+24
View File
@@ -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 усечённые не отдаёт).
+22
View File
@@ -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) {
+6 -5
View File
@@ -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)
+32 -5
View File
@@ -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
}
}
+35
View File
@@ -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"))
+19 -17
View File
@@ -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 могла завладеть другая активная задача — тогда
+49
View File
@@ -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,
@@ -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`), контроль перецепки здорового.
- **Миграции БД:** нет.
@@ -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` (не остаётся без раздачи)
@@ -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`