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