Жизненный цикл: 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:
@@ -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 усечённые не отдаёт).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 могла завладеть другая активная задача — тогда
|
||||
|
||||
@@ -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`), контроль перецепки здорового.
|
||||
- **Миграции БД:** нет.
|
||||
+84
@@ -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`
|
||||
Reference in New Issue
Block a user