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