Жизненный цикл: 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user