Жизненный цикл: 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
+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 могла завладеть другая активная задача — тогда