Жизненный цикл: 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
+35
View File
@@ -315,6 +315,10 @@ type memStore struct {
// Инъекция сбоев (для тестов устойчивости раскладки).
failCreateLinks error // CreateFileLinks вернёт эту ошибку
failSetState func(store.State) error // SetDownloadState вернёт ошибку для перехода
// clock — монотонный счётчик для updated_at (как реальный store бьёт метку на
// каждом переходе); питает проверку claim-токена распознавания (MINOR-8).
clock int
}
func newMemStore() *memStore {
@@ -450,6 +454,8 @@ func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State
d.State = st
d.ErrorCode = store.NullString(code)
d.ErrorMsg = store.NullString(msg)
m.clock++
d.UpdatedAt = itoa(m.clock) // свежая метка перехода — claim-токен (MINOR-8)
return nil
}
@@ -810,6 +816,35 @@ func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
}
}
// TestRecognizeOne_DiscardsStaleClaim — MINOR-8: если во время вызова LLM задачу
// увели из recognizing и вернули обратно (cancel → relink revive), это уже другой
// recognizing-эпизод (updated_at сменился). Устаревший результат прежнего прогона
// отбрасывается, задача остаётся в recognizing для перезапуска поллингом.
func TestRecognizeOne_DiscardsStaleClaim(t *testing.T) {
st := newMemStore()
st.put(completedDownload("1"))
qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}},
}
// Во время вызова LLM задачу уводят из recognizing и возвращают обратно
// (симуляция cancel → relink): состояние снова recognizing, но updated_at сменился.
rec := &fakeRecognizer{result: seriesResult(), onCall: func() {
_ = st.SetDownloadState(context.Background(), "1", store.StateCancelled, "", "")
_ = st.SetDownloadState(context.Background(), "1", store.StateRecognizing, "", "")
}}
w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), "1")
if st.downloads["1"].State != store.StateRecognizing {
t.Errorf("state = %q, want recognizing (устаревший результат отброшен, ждём перезапуска)", st.downloads["1"].State)
}
if cur, _ := st.GetCurrentRecognition(context.Background(), "1"); cur != nil {
t.Error("recognition устаревшей попытки не должна сохраняться")
}
}
func TestRecognizeOne_SignalsErrorToReview(t *testing.T) {
st := newMemStore()
st.put(completedDownload("1"))