Жизненный цикл: 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
+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,