From 8261d5b55d1a599f6781d94ca9b9bbd56dc36258 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Wed, 8 Jul 2026 17:12:01 +0300 Subject: [PATCH] =?UTF-8?q?Retry/stall:=20=D1=81=D0=B1=D1=80=D0=BE=D1=81?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B7=D0=B8=D1=81=D0=B0=20=D1=82=D0=B0=D0=B9?= =?UTF-8?q?=D0=BC=D0=B0=D1=83=D1=82=D0=B0=20+=20=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B9=20=D0=BE=D1=82=20last=5Factivity=20(MAJOR-1,?= =?UTF-8?q?=20MAJOR-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два связанных бага семантики таймаутов зависания и ручного retry. MAJOR-1: Retry живого торрента не сбрасывал базис отсчёта таймаута — задача мгновенно снова падала в stuck на ближайшем тике. Вводим колонку download.retried_at (миграция 0010): ручной retry фиксирует момент и приподнимает пол обоих таймаутов (max(базис, retried_at)). Хранится в БД, а не в памяти, чтобы сброс пережил тик поллинга и рестарт. MAJOR-2: stuck_after мерил ВОЗРАСТ торрента (от added_on), а не ПРОСТОЙ — долго качавшийся торрент, на миг зашедший в stalledDL, ложно уходил в stuck со «stalled for 5h». Теперь stuck_after мерит простой от qBit last_activity (новое поле qbt.Torrent из того же ответа /torrents/info); magnet_timeout по-прежнему мерит возраст (семантически верно). checkTimeouts разбит на torrentAge/stallDuration/addedBasis/retriedFloor. NIT-10: фолбэк базиса возраста added_on→created_at сохранён и покрыт. NIT-12: retry перестаёт перецепляться к сломанному живому торренту (error/missingFiles) — повторно отдаёт источник (перецепка к нему бессмысленна: reconcile тут же вернул бы в failed). Спека: дельта state-reconciliation (MODIFIED «Восстановление зависшей загрузки» и «Ручной повтор»), правка docs/specs/workflow.md (устранено противоречие «возраст vs простой»), ER-схема database.md. Тесты: TestRetryResetsTimeoutBasis (следующий тик после retry — прячется в TestRetryReattachesNoReadd), TestStallMeasuredFromLastActivity, TestSetRetriedAtOverwrites. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/specs/database.md | 1 + docs/specs/workflow.md | 23 ++- internal/qbt/qbt.go | 9 +- internal/store/download.go | 38 ++++- internal/store/list_test.go | 28 ++++ internal/store/migrations/0010_retried_at.sql | 11 ++ internal/worker/recovery_test.go | 71 ++++++++ internal/worker/review_test.go | 7 + internal/worker/worker.go | 120 ++++++++++---- internal/worker/worker_test.go | 9 + .../changes/retry-stall-basis/.openspec.yaml | 2 + openspec/changes/retry-stall-basis/design.md | 104 ++++++++++++ .../changes/retry-stall-basis/proposal.md | 78 +++++++++ .../specs/state-reconciliation/spec.md | 154 ++++++++++++++++++ openspec/changes/retry-stall-basis/tasks.md | 44 +++++ 15 files changed, 659 insertions(+), 40 deletions(-) create mode 100644 internal/store/migrations/0010_retried_at.sql create mode 100644 openspec/changes/retry-stall-basis/.openspec.yaml create mode 100644 openspec/changes/retry-stall-basis/design.md create mode 100644 openspec/changes/retry-stall-basis/proposal.md create mode 100644 openspec/changes/retry-stall-basis/specs/state-reconciliation/spec.md create mode 100644 openspec/changes/retry-stall-basis/tasks.md diff --git a/docs/specs/database.md b/docs/specs/database.md index f5586b4..b562c64 100644 --- a/docs/specs/database.md +++ b/docs/specs/database.md @@ -45,6 +45,7 @@ erDiagram TEXT error_msg "nullable" INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)" TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)" + TEXT retried_at "nullable; время последнего ручного retry (RFC 3339 UTC Z), сброс базиса таймаутов (миграция 0010)" TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } diff --git a/docs/specs/workflow.md b/docs/specs/workflow.md index ed845f4..476fdd3 100644 --- a/docs/specs/workflow.md +++ b/docs/specs/workflow.md @@ -157,12 +157,17 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и - **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/ `queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`. - **застряло по таймауту (страховка):** `metaDL`/`forcedMetaDL` дольше - `magnet_timeout` → `failed`; `stalledDL` дольше `stuck_after` → `stuck`. - `magnet_timeout` — **редкий страховочный предохранитель** (дефолт `24h`), а - не рабочий механизм: долгий `metaDL` (медленные трекеры/мало пиров) — это - норма, его не убиваем агрессивно. Возраст считаем от времени добавления - торрента в qBittorrent (`added_on`), а не от создания задачи (базис - переживает retry и усыновление). + `magnet_timeout` → `failed`; `stalledDL` **простаивающий** дольше + `stuck_after` → `stuck`. `magnet_timeout` — **редкий страховочный + предохранитель** (дефолт `24h`), а не рабочий механизм: долгий `metaDL` + (медленные трекеры/мало пиров) — это норма, его не убиваем агрессивно. Меры у + двух таймаутов **разные**: `magnet_timeout` мерит **возраст** торрента от + добавления в qBittorrent (`added_on`, фолбэк `created_at`); `stuck_after` + мерит **длительность простоя** — от `last_activity` (последнее движение + данных), а не возраст, иначе долго качавшийся торрент, на миг зашедший в + `stalledDL`, ложно уходит в `stuck` со «stalled for 5h». Оба базиса + приподнимаются до `retried_at` — ручной retry сбрасывает отсчёт, чтобы возврат + в `downloading` не ронял задачу снова на ближайшем тике. - **ошибка:** `error`/`missingFiles` → `failed` (`error_code` `qbit_error`) — это настоящий провал, в отличие от таймаута. @@ -181,8 +186,10 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания). Настоящие провалы (`qbit_error`) сверкой не воскрешаются. - Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только - REST): возвращает в `downloading`, перецепляясь к живому торренту без - повторного `Add`. + REST): возвращает в `downloading`, перецепляясь к живому **здоровому** торренту + без повторного `Add` (к сломанному — `error`/`missingFiles` — не + перецепляемся, повторно отдаём источник) и сбрасывая базис таймаутов + (`retried_at`), чтобы задача не упала снова на ближайшем тике. Пути файлов берём из API (`save_path` + относительные имена из `/torrents/files`, уже включающие корневую папку торрента), не из diff --git a/internal/qbt/qbt.go b/internal/qbt/qbt.go index a55049e..a55aa40 100644 --- a/internal/qbt/qbt.go +++ b/internal/qbt/qbt.go @@ -60,8 +60,13 @@ type Torrent struct { AmountLeft int64 `json:"amount_left"` TotalSize int64 `json:"total_size"` // полный размер раздачи, байт AddedOn int64 `json:"added_on"` - InfohashV1 string `json:"infohash_v1"` - InfohashV2 string `json:"infohash_v2"` + // LastActivity — Unix-время последнего движения данных по торренту (скачан/ + // отдан кусок). Базис измерения простоя для stuck_after: простой = now − + // last_activity (а не возраст от added_on), поэтому долго качавшийся торрент, + // на миг зашедший в stalledDL, не помечается «зависшим». + LastActivity int64 `json:"last_activity"` + InfohashV1 string `json:"infohash_v1"` + InfohashV2 string `json:"infohash_v2"` // Живая телеметрия (для снимка воркера и веб-UI). Dlspeed int64 `json:"dlspeed"` // скорость загрузки, байт/с diff --git a/internal/store/download.go b/internal/store/download.go index 174aa25..b593128 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -183,8 +183,13 @@ type Download struct { // сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в // формате RFC 3339 (UTC, суффикс Z), как created_at. SourceAddedAt sql.NullString `db:"source_added_at"` - CreatedAt string `db:"created_at"` - UpdatedAt string `db:"updated_at"` + // RetriedAt — время последнего ручного retry (RFC 3339 UTC, суффикс Z), NULL + // пока задачу не повторяли. Приподнимает базис отсчёта таймаутов, чтобы + // возврат в downloading не ронял задачу снова на ближайшем тике (см. + // state-reconciliation «Ручной повтор»). + RetriedAt sql.NullString `db:"retried_at"` + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` // Infohashes — хеши загрузки (download_infohash); подгружаются вместе с // записью методами чтения store (v1 раньше v2 — порядок стабильный). @@ -236,6 +241,19 @@ func Now() time.Time { return time.Now().UTC() } // CreatedTime возвращает время создания загрузки как time.Time (UTC). func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) } +// RetriedTime возвращает время последнего ручного retry (UTC) и ok=false, если +// задачу ещё не повторяли (retried_at NULL) или метку не разобрать. +func (d Download) RetriedTime() (time.Time, bool) { + if !d.RetriedAt.Valid { + return time.Time{}, false + } + t, err := ParseTime(d.RetriedAt.String) + if err != nil { + return time.Time{}, false + } + return t, true +} + // NullString строит sql.NullString: пустая строка → NULL. func NullString(s string) sql.NullString { return sql.NullString{String: s, Valid: s != ""} @@ -504,6 +522,22 @@ func (s *Store) SetSourceAddedAt(ctx context.Context, id string, t time.Time) er return nil } +// SetRetriedAt проставляет время ручного retry задачи (сброс базиса отсчёта +// таймаутов, см. state-reconciliation «Ручной повтор»). В отличие от +// SetSourceAddedAt перезаписывает значение: retry можно повторять, и базис +// должен смещаться на каждый. +func (s *Store) SetRetriedAt(ctx context.Context, id string, t time.Time) error { + res, err := s.DB.ExecContext(ctx, + `UPDATE download SET retried_at = ? WHERE id = ?`, FormatTime(t), id) + if err != nil { + return fmt.Errorf("set download %s retried_at: %w", id, err) + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("set download %s retried_at: not found", id) + } + return nil +} + // ListDownloads возвращает все загрузки, новые сверху (id — ULID, сортировка // по нему хронологична). func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) { diff --git a/internal/store/list_test.go b/internal/store/list_test.go index a2b3a60..cf0ec78 100644 --- a/internal/store/list_test.go +++ b/internal/store/list_test.go @@ -225,6 +225,34 @@ func TestSetSourceAddedAtOnce(t *testing.T) { } } +func TestSetRetriedAtOverwrites(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + + id := mkDownload(t, st, 1, StateStuck, "x") + first := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC) + second := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC) + + if err := st.SetRetriedAt(ctx, id, first); err != nil { + t.Fatal(err) + } + // В отличие от source_added_at — повторный retry перезаписывает базис. + if err := st.SetRetriedAt(ctx, id, second); err != nil { + t.Fatal(err) + } + d, err := st.GetDownload(ctx, id) + if err != nil { + t.Fatal(err) + } + if !d.RetriedAt.Valid || d.RetriedAt.String != FormatTime(second) { + t.Fatalf("retried_at = %q, want %q (последний retry)", d.RetriedAt.String, FormatTime(second)) + } + got, ok := d.RetriedTime() + if !ok || !got.Equal(second) { + t.Fatalf("RetriedTime() = %v, %v; want %v, true", got, ok, second) + } +} + func TestListDownloadsPageRecTitleFallback(t *testing.T) { st := newTestStore(t) ctx := context.Background() diff --git a/internal/store/migrations/0010_retried_at.sql b/internal/store/migrations/0010_retried_at.sql new file mode 100644 index 0000000..be269a2 --- /dev/null +++ b/internal/store/migrations/0010_retried_at.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Время последнего ручного retry задачи (RFC 3339 UTC, суффикс Z). Приподнимает +-- базис отсчёта таймаутов (magnet_timeout/stuck_after): после retry отсчёт идёт +-- от max(базис_добавления или last_activity, retried_at), чтобы возврат в +-- downloading не ронял задачу снова на ближайшем тике (см. state-reconciliation +-- «Ручной повтор зависшей/упавшей загрузки»). Хранится в БД, а не в памяти, +-- чтобы сброс базиса пережил интервал поллинга и рестарт процесса. +ALTER TABLE download ADD COLUMN retried_at TEXT; + +-- +goose Down +ALTER TABLE download DROP COLUMN retried_at; diff --git a/internal/worker/recovery_test.go b/internal/worker/recovery_test.go index d808996..d185de9 100644 --- a/internal/worker/recovery_test.go +++ b/internal/worker/recovery_test.go @@ -168,3 +168,74 @@ func TestRetryReattachesNoReadd(t *testing.T) { t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State) } } + +// addedLongAgo — added_on «5 часов назад» относительно now теста (10:00:00 UTC): +// торрент давно в qBittorrent (возраст сам по себе большой). +var addedLongAgo = time.Date(2026, 6, 14, 5, 0, 0, 0, time.UTC).Unix() + +// lastActivityLongAgo — last_activity «2 часа назад»: данные давно не двигались. +var lastActivityLongAgo = time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC).Unix() + +// MAJOR-1: retry живого, но давно добавленного и простаивающего stalledDL-торрента +// сбрасывает базис таймаута (retried_at), поэтому СЛЕДУЮЩИЙ тик не роняет задачу +// снова в stuck. Без сброса базиса stallDuration=now−last_activity (2ч) > StuckAfter +// (1ч) → задача мгновенно вернулась бы в stuck (регрессия, которую прячет +// TestRetryReattachesNoReadd, ставящий added_on/last_activity «минуту назад»). +func TestRetryResetsTimeoutBasis(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneFailed(store.StateStuck, errCodeStalled, ih, timeOld) + // Торрент жив, но давно добавлен (added_on 5ч) и давно простаивает + // (last_activity 2ч) — по старой мере «возраст» он мгновенно снова stuck. + qb := &fakeQbt{torrents: []qbt.Torrent{{ + Hash: ih, State: "stalledDL", + AddedOn: addedLongAgo, LastActivity: lastActivityLongAgo, + }}} + 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("живой здоровый торрент не должен добавляться повторно, got %d Add", len(qb.added)) + } + if !st.downloads["1"].RetriedAt.Valid { + t.Error("retry должен проставить retried_at (сброс базиса)") + } + // Ключевая проверка MAJOR-1: следующий тик поллинга. + if err := w.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if got := st.downloads["1"].State; got != store.StateDownloading { + t.Errorf("после retry задача не должна снова падать в stuck на ближайшем тике, got %q", got) + } +} + +// MAJOR-2: stuck_after мерит ДЛИТЕЛЬНОСТЬ ПРОСТОЯ (now−last_activity), а не возраст +// торрента. Долго качавшийся торрент (added_on 5ч назад) с недавним движением +// данных (last_activity 30с назад), на миг зашедший в stalledDL, НЕ уходит в stuck. +func TestStallMeasuredFromLastActivity(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + lastActivityRecent := time.Date(2026, 6, 14, 9, 59, 30, 0, time.UTC).Unix() // 30с назад + st := oneDownloading(ih, timeOld) + qb := &fakeQbt{torrents: []qbt.Torrent{{ + Hash: ih, State: "stalledDL", + AddedOn: addedLongAgo, LastActivity: lastActivityRecent, + }}} + w := newTestWorker(st, qb) + if err := w.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if got := st.downloads["1"].State; got != store.StateDownloading { + t.Errorf("свежая активность (30с) — не stuck несмотря на возраст 5ч, got %q", got) + } + + // Контроль: тот же торрент, но данные давно не двигались (last_activity 2ч) — + // простой превысил StuckAfter (1ч) → stuck. + qb.torrents[0].LastActivity = lastActivityLongAgo + if err := w.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if got := st.downloads["1"].State; got != store.StateStuck { + t.Errorf("простой 2ч > stuck_after 1ч должен дать stuck, got %q", got) + } +} diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index b1d56d2..ee1d9c0 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -467,6 +467,13 @@ func (m *memStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) e return nil } +func (m *memStore) SetRetriedAt(_ context.Context, id string, t time.Time) error { + if d, ok := m.downloads[id]; ok { + d.RetriedAt = store.NullString(store.FormatTime(t)) + } + return nil +} + func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (string, error) { for _, e := range m.recs { if e.DownloadID == r.DownloadID { diff --git a/internal/worker/worker.go b/internal/worker/worker.go index b6d5d5b..05d7f2d 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -51,6 +51,10 @@ type Store interface { PromoteCatched(ctx context.Context, id, displayName string) error SetSourceMissCount(ctx context.Context, id string, n int) error SetSourceAddedAt(ctx context.Context, id string, t time.Time) error + // SetRetriedAt проставляет время ручного retry — сброс базиса отсчёта + // таймаутов (magnet_timeout/stuck_after), чтобы возврат в downloading не + // ронял задачу снова на ближайшем тике. + SetRetriedAt(ctx context.Context, id string, t time.Time) error // Идентичность/инвариант «одна активная загрузка на infohash». ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error) @@ -506,21 +510,29 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent) } } -// checkTimeouts помечает зависшие задачи. Возраст считаем от факта в -// qBittorrent (added_on), а не от created_at: базис переживает retry и -// усыновление раздачи (см. design download-failure-recovery). magnet_timeout — -// редкий страховочный предохранитель (дефолт 24h); настоящие провалы ловит +// checkTimeouts помечает зависшие задачи двумя разными мерами: +// - magnet_timeout — по ВОЗРАСТУ торрента (metaDL дольше magnet_timeout без +// метаданных): страховочный предохранитель (дефолт 24h), базис — added_on; +// - stuck_after — по ДЛИТЕЛЬНОСТИ ПРОСТОЯ (stalledDL без движения данных +// дольше stuck_after): базис — last_activity, а не возраст. Иначе долго +// качавшийся торрент, на миг зашедший в stalledDL, ложно уходит в stuck со +// «stalled for 5h» (см. state-reconciliation, MAJOR-2). +// +// Оба базиса приподняты до retried_at (ручной retry), чтобы возврат в +// downloading не ронял задачу снова на ближайшем тике. Настоящие провалы ловит // classErrored, а ожившие задачи воскрешает reconcileRecovery. func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torrent) { - age := w.torrentAge(d, t) - switch { - case isMeta(t.State) && w.cfg.MagnetTimeout > 0 && age > w.cfg.MagnetTimeout: - w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout, - fmt.Sprintf("no metadata after %s", age.Truncate(time.Second))) - case isStalledDL(t.State) && w.cfg.StuckAfter > 0 && age > w.cfg.StuckAfter: - w.transition(ctx, d, store.StateStuck, errCodeStalled, - fmt.Sprintf("stalled for %s", age.Truncate(time.Second))) + case isMeta(t.State) && w.cfg.MagnetTimeout > 0: + if age, ok := w.torrentAge(d, t); ok && age > w.cfg.MagnetTimeout { + w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout, + fmt.Sprintf("no metadata after %s", age.Truncate(time.Second))) + } + case isStalledDL(t.State) && w.cfg.StuckAfter > 0: + if idle, ok := w.stallDuration(d, t); ok && idle > w.cfg.StuckAfter { + w.transition(ctx, d, store.StateStuck, errCodeStalled, + fmt.Sprintf("stalled for %s", idle.Truncate(time.Second))) + } } } @@ -572,23 +584,61 @@ func (w *Worker) captureInfohashes(ctx context.Context, d store.Download, t qbt. } } -// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис, -// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не -// отдал added_on. -func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) time.Duration { +// torrentAge — возраст торрента для magnet_timeout: от added_on в qBittorrent +// (надёжный базис, переживает усыновление), с фолбэком на created_at задачи, +// если qBit не отдал added_on (NIT-10). Базис приподнят до retried_at, чтобы +// ручной retry сбрасывал отсчёт. ok=false — базис неизвестен (ни added_on, ни +// разбираемого created_at): таймаут не срабатывает, фиксируем диагностикой. +func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) (time.Duration, bool) { + basis, ok := w.addedBasis(d, t) + if !ok { + return 0, false + } + return w.now().Sub(w.retriedFloor(d, basis)), true +} + +// stallDuration — длительность простоя торрента для stuck_after: от +// last_activity qBittorrent (момент последнего движения данных), с фолбэком на +// базис добавления, если qBit не отдал last_activity. Базис приподнят до +// retried_at (ручной retry даёт свежее окно). ok=false — базис неизвестен. +func (w *Worker) stallDuration(d store.Download, t qbt.Torrent) (time.Duration, bool) { + var basis time.Time + if t.LastActivity > 0 { + basis = time.Unix(t.LastActivity, 0).UTC() + } else { + var ok bool + if basis, ok = w.addedBasis(d, t); !ok { + return 0, false + } + } + return w.now().Sub(w.retriedFloor(d, basis)), true +} + +// addedBasis — момент добавления торрента: added_on qBittorrent, иначе +// created_at задачи (NIT-10). ok=false — ни того, ни другого разобрать не +// удалось; фиксируем диагностикой. +func (w *Worker) addedBasis(d store.Download, t qbt.Torrent) (time.Time, bool) { if t.AddedOn > 0 { - return w.now().Sub(time.Unix(t.AddedOn, 0).UTC()) + return time.Unix(t.AddedOn, 0).UTC(), true } created, err := d.CreatedTime() if err != nil { - // Ни added_on от qBit, ни разбираемого created_at — возраст неизвестен, - // таймауты не сработают; фиксируем диагностикой. w.log.Warn("cannot determine torrent age", "capability", capIngest, "download_id", d.ID, "created_at", d.CreatedAt, "error", err) - return 0 + return time.Time{}, false } - return w.now().Sub(created) + return created, true +} + +// retriedFloor приподнимает базис отсчёта таймаута до времени последнего ручного +// retry: после retry задача получает свежее окно и не падает повторно на +// ближайшем тике (см. state-reconciliation «Ручной повтор», MAJOR-1). +func (w *Worker) retriedFloor(d store.Download, basis time.Time) time.Time { + if r, ok := d.RetriedTime(); ok && r.After(basis) { + return r + } + return basis } // transition пишет новое состояние и логирует переход. @@ -685,16 +735,22 @@ func (w *Worker) Retry(ctx context.Context, id string) error { if d.State != store.StateFailed && d.State != store.StateStuck { return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State) } - // Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add - // не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда - // источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому - // возврат в downloading не роняет задачу снова на ближайшем тике. - alive := false + // Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней, + // повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add — + // когда источника в qBittorrent нет ИЛИ он в состоянии ошибки: перецепка к + // сломанному торренту (error/missingFiles) бессмысленна — reconcile тут же + // вернул бы задачу в failed, поэтому пробуем повторно отдать источник + // (NIT-12). Базис таймаута сбрасывается ниже через retried_at, поэтому + // возврат в downloading не роняет задачу снова на ближайшем тике (MAJOR-1). + reAdd := true if hashes := d.HashList(); len(hashes) > 0 { - _, alive, err = w.torrentByInfohash(ctx, hashes) + var t qbt.Torrent + var alive bool + t, alive, err = w.torrentByInfohash(ctx, hashes) if err != nil { return fmt.Errorf("retry: %w", err) } + reAdd = !alive || classify(t.State) == classErrored } // Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала // в failed, тем же infohash могла завладеть другая активная задача — тогда @@ -705,7 +761,7 @@ func (w *Worker) Retry(ctx context.Context, id string) error { } return fmt.Errorf("retry: %w", err) } - if !alive { + if reAdd { // Добавляем заново по типу источника (magnet — ссылкой, torrent — // сохранёнными байтами файлом). Rename при retry не выводим (namer здесь // не зовём — имя уже могло быть выведено при первом добавлении). @@ -729,6 +785,14 @@ func (w *Worker) Retry(ctx context.Context, id string) error { return fmt.Errorf("retry: add to qbittorrent: %w", err) } } + // Сброс базиса отсчёта таймаутов (MAJOR-1): без него живой, но давно + // добавленный/простаивающий торрент снова упал бы по magnet_timeout/ + // stuck_after на ближайшем тике. Best-effort: сбой лишь лишает свежего окна + // (WARN), сам retry уже состоялся. + if err := w.store.SetRetriedAt(ctx, id, w.now()); err != nil { + w.log.Warn("retry basis reset failed", + "capability", capReview, "download_id", id, "error", err) + } logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State) return nil } diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index e78b374..6d7cd88 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -211,6 +211,15 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) return nil } +func (f *fakeStore) SetRetriedAt(_ context.Context, id string, t time.Time) error { + d, ok := f.downloads[id] + if !ok { + return fmt.Errorf("download %s not found", id) + } + d.RetriedAt = store.NullString(store.FormatTime(t)) + return nil +} + // --- Ф3-методы Store (заглушки; переопределяются в review_test.go) --- func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) { diff --git a/openspec/changes/retry-stall-basis/.openspec.yaml b/openspec/changes/retry-stall-basis/.openspec.yaml new file mode 100644 index 0000000..8cceb8d --- /dev/null +++ b/openspec/changes/retry-stall-basis/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-08 diff --git a/openspec/changes/retry-stall-basis/design.md b/openspec/changes/retry-stall-basis/design.md new file mode 100644 index 0000000..25f4ad2 --- /dev/null +++ b/openspec/changes/retry-stall-basis/design.md @@ -0,0 +1,104 @@ +# Design: retry/stall basis + +## Контекст + +Два таймаута зависания в `Worker.checkTimeouts` сегодня используют один базис — +возраст торрента `now − added_on`: + +- `magnet_timeout`: `metaDL` дольше порога → `failed/magnet_timeout`. +- `stuck_after`: `stalledDL` дольше порога → `stuck/stalled`. + +Для `magnet_timeout` возраст семантически верен (сколько торрент вообще висит +без метаданных). Для `stuck_after` возраст НЕВЕРЕН: нас интересует **простой** +(сколько данные не двигаются), а не общий возраст (MAJOR-2). Отдельно retry +живого торрента не сбрасывает базис, и задача мгновенно снова падает (MAJOR-1). + +## Дизайн-развилка: откуда брать базис простоя/таймаута + +Ключевое решение change — где взять базис для двух мер. Рассмотрены варианты: + +- **(a) Новые колонки БД** `retried_at` и/или `stalled_since`. Базис возраста = + `max(added_on, retried_at)`; простой — от `stalled_since` (момент входа в + `stalledDL`, который мы сами детектируем и пишем/сбрасываем на каждом тике). +- **(b) Переиспользовать `last_activity` из снимка торрента** для измерения + простоя, без колонки на stall. +- **(c) Гибрид (ВЫБРАН):** колонка `retried_at` (только для сброса базиса при + ручном retry) + `last_activity` qBittorrent (для измерения простоя). Колонки + `stalled_since` НЕТ. + +### Выбор: (c) `retried_at` (БД) + `last_activity` (qBit) + +**Простой мерим по `last_activity`, а не по `stalled_since`-колонке.** +qBittorrent уже отдаёт `last_activity` (Unix-время последнего движения данных) +в том же ответе `/torrents/info` — это авторитетный источник «сколько простой» +прямо из движка. `stalled_since` дублировал бы это состояние, требовал бы +детектировать переход «вход в stalledDL», писать/сбрасывать колонку на КАЖДОМ +тике (торренты мерцают `stalledDL`↔`downloading`) и рисковал бы разъездом с +собственным взглядом qBittorrent. Простой = `now − last_activity`: торрент, +двигавший данные секунду назад, простаивает ~0 несмотря на возраст 5ч → +MAJOR-2 закрыт без схемы для stall. Это часть варианта (b). + +**Сброс базиса при retry храним в `retried_at` (БД), а не в памяти.** Спека +требует, чтобы retry давал свежее окно и задача не падала снова. `last_activity` +этого не выражает: у по-настоящему простаивающего торрента она «часы назад», и +возврат в `downloading` тут же дал бы `stuck` на следующем тике. Нужна +персистентная метка «пользователь нажал retry в момент T», которая приподнимает +пол ОБОИХ базисов: `basis = max(добавление|last_activity, retried_at)`. Она +должна пережить интервал поллинга и рестарт процесса (retry, затем рестарт не +должен ронять задачу), поэтому — колонка, а не in-memory map. Это часть варианта +(a), но минимальная: одна nullable TEXT-колонка. Закрывает MAJOR-1. + +### Почему не чистые (a) или (b) + +- **Чистый (b) без колонки** — нельзя записать `last_activity` qBittorrent, так + что retry не смог бы сдвинуть базис → MAJOR-1 не решается. +- **Чистый (a) со `stalled_since`** — лишняя колонка + пер-тиковая + бухгалтерия входа/выхода из `stalledDL`, дублирующая `last_activity`. Отвергнут + на минимальности схемы и единственном источнике истины. + +### Замечание об интеграции (точка человеческого вето) + +Change читает НОВОЕ поле qBittorrent `last_activity`, но НЕ добавляет нового +вызова API или интеграционной поверхности — поле уже приходит в ответе +`/torrents/info`, парсим на одно поле больше. Это единственная «интеграция» +change, и она безопасна. Более глубокая интеграция для полного решения NIT-12 +(см. ниже) СОЗНАТЕЛЬНО отложена как точка человеческого вето. + +## Итоговая схема базисов + +``` +magnetAge = now − max( added_on | created_at(fallback), retried_at ) // magnet_timeout +stallIdle = now − max( last_activity | added_on|created_at(fallback), retried_at ) // stuck_after +``` + +- `addedBasis(d,t)` — `added_on`, иначе `created_at` (NIT-10), иначе базис + неизвестен (WARN, таймаут не срабатывает). +- `retriedFloor(d,basis)` — приподнимает базис до `retried_at`, если он позже. +- `retried_at` не чистится: как только данные двинулись, `last_activity` + естественно обгоняет `retried_at`, и пол перестаёт влиять. + +## NIT-12: retry сломанного живого торрента + +Живой торрент в `error`/`missingFiles` (класс `classErrored`) — перецепка к +нему бессмысленна: reconcile на ближайшем тике вернёт задачу в `failed`. Retry +теперь считает такой торрент «неживым для целей перецепки» и идёт по ветке +повторного `Add` (повторно отдаёт источник). Это честнее слепой перецепки: +retry перецепляется только к ЗДОРОВОМУ живому торренту. + +**Остаточное ограничение (отложено, точка вето):** для устойчиво сломанного +торрента повторный `Add` того же infohash qBittorrent, как правило, дедуплицирует +— ошибка не очистится, и следующий тик всё равно вернёт задачу в `failed`. Полное +устранение (принудительный recheck / delete+re-add через qBittorrent) требует +НОВОЙ интеграции с клиентом и вынесено за рамки change на человеческое решение. + +## Тесты + +- `TestRetryResetsTimeoutBasis` — MAJOR-1: retry живого stalledDL-торрента с + давним `added_on` и давним `last_activity`, затем СЛЕДУЮЩИЙ тик Poll → + остаётся `downloading` (без сброса базиса ушёл бы в `stuck`). Именно эту + регрессию прячет `TestRetryReattachesNoReadd`. +- `TestStallMeasuredFromLastActivity` — MAJOR-2: `stalledDL` с давним `added_on`, + но свежим `last_activity` → `downloading`; контроль — давняя `last_activity` → + `stuck`. +- `TestSetRetriedAtOverwrites` — store: `retried_at` перезаписывается (в отличие + от однократного `source_added_at`). diff --git a/openspec/changes/retry-stall-basis/proposal.md b/openspec/changes/retry-stall-basis/proposal.md new file mode 100644 index 0000000..b335a8c --- /dev/null +++ b/openspec/changes/retry-stall-basis/proposal.md @@ -0,0 +1,78 @@ +## Why + +Два связанных бага в семантике таймаутов зависания и ручного retry делают +повседневные сценарии сломанными: + +- **MAJOR-1 — retry живого торрента мгновенно снова падает.** `Worker.Retry` + при живой раздаче (`alive=true`) не переиздаёт `Add`, а лишь возвращает + задачу в `downloading`. Базис отсчёта таймаута (`age = now − added_on`) при + этом НЕ сбрасывается. Если торрент давно добавлен/давно простаивает, + ближайший тик снова видит `stalledDL && age > stuck_after` → задача опять + уходит в `stuck` (~секунды). Спека `state-reconciliation` «Ручной повтор» + требует сброса базиса, но код его не выполняет (комментарий «базис от + added_on» верен лишь для ветки повторного `Add`). Существующий тест + `TestRetryReattachesNoReadd` прячет баг, ставя `added_on` «минуту назад». + +- **MAJOR-2 — `stuck_after` мерит ВОЗРАСТ, а не ПРОСТОЙ.** `checkTimeouts` + считает `stalledDL`-таймаут от `added_on` (возраст торрента). Торрент, + качавшийся 5 часов и на один тик зашедший в `stalledDL` (нормальный проход + между пирами), мгновенно получает `stuck` со лживым сообщением «stalled for + 5h» и уведомление `EventFailed`. Результат — флап `stuck`↔`downloading` и + до-часовые ложные пинги. Спека сама противоречива: «`stalledDL` дольше + `stuck_after`» (простой) против «возраст от `added_on`». + +Дополнительно закрываются два NIT из того же ревью: + +- **NIT-10** — фолбэк базиса возраста `added_on → created_at` (когда qBit не + отдал `added_on`) остаётся, но теперь явно документирован и покрыт. +- **NIT-12** — retry задачи в `qbit_error` мгновенно откатывается: перецепка к + сломанному (`error`/`missingFiles`) живому торренту бессмысленна — reconcile + тут же возвращает задачу в `failed`. Retry перестаёт перецепляться к + сломанному торренту и повторно отдаёт источник. + +## What Changes + +- **Мера простоя вместо возраста для `stuck_after`.** `stalledDL`-таймаут + считается от `last_activity` qBittorrent (момент последнего движения данных), + а не от возраста торрента. Долго качавшийся торрент со свежей активностью в + `stuck` не уходит (MAJOR-2). `magnet_timeout` по-прежнему мерит **возраст** + (`metaDL` без метаданных) от `added_on` — это семантически верно. +- **Сброс базиса таймаутов при ручном retry.** Новая колонка `download.retried_at` + (RFC 3339 UTC) фиксирует момент retry и приподнимает базис ОБОИХ таймаутов + (`max(базис, retried_at)`). После retry задача получает свежее окно и не + падает снова на ближайшем тике (MAJOR-1). Хранится в БД (не в памяти), чтобы + сброс пережил интервал поллинга и рестарт процесса. +- **Retry не перецепляется к сломанному торренту.** Если живой торрент в + состоянии ошибки qBittorrent (`error`/`missingFiles`), retry повторно отдаёт + источник вместо перецепки (NIT-12). + +## Capabilities + +### New Capabilities + +Нет. Семантика таймаутов зависания относится к жизненному циклу загрузки, +который пока живёт в `docs/specs/workflow.md` (не мигрирован в OpenSpec). +Нормативная правка `stuck_after`/`magnet_timeout` вносится туда; в OpenSpec +затрагивается только `state-reconciliation` (восстановление и ручной retry). + +### Modified Capabilities + +- `state-reconciliation`: уточняется, что предотвращение `stuck` для + прогрессирующего торрента опирается на **простой от `last_activity`**, а не + на возраст; ручной retry сбрасывает базис таймаутов через `retried_at` и не + перецепляется к сломанному живому торренту. + +## Impact + +- **Спеки:** дельта `state-reconciliation` (2 MODIFIED requirements); + правка семантики таймаутов и retry в `docs/specs/workflow.md` (источник + истины по жизненному циклу до миграции). +- **Код:** `internal/worker/worker.go` — `checkTimeouts` (две разные меры), + `torrentAge`/новые `stallDuration`/`addedBasis`/`retriedFloor`, `Retry` + (сброс базиса + перецепка только к здоровому торренту); `internal/qbt` + (поле `last_activity`); `internal/store/download.go` (`RetriedAt`, + `RetriedTime`, `SetRetriedAt`). +- **Миграции БД:** `0010_retried_at.sql` — колонка `download.retried_at`; + обновление ER-схемы `docs/specs/database.md`. +- **qBittorrent-клиент:** читается новое поле `last_activity` из того же + ответа `/torrents/info` (без нового вызова API). diff --git a/openspec/changes/retry-stall-basis/specs/state-reconciliation/spec.md b/openspec/changes/retry-stall-basis/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..2378f38 --- /dev/null +++ b/openspec/changes/retry-stall-basis/specs/state-reconciliation/spec.md @@ -0,0 +1,154 @@ +## MODIFIED Requirements + +### Requirement: Восстановление зависшей загрузки при оживлении источника + +Система SHALL возвращать в активный поток задачу, упавшую из-за нашей +нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её +источник в qBittorrent жив и продвинулся: переход выводится из текущего +состояния торрента так же, как при штатной сверке загрузки +(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… → +`downloading`). Восстановление SHALL опираться на фактическое состояние +торрента в qBittorrent, а не на время с момента создания записи. + +После возврата в любое нетерминальное состояние (`downloading` или +`completed`) повторный приём того же infohash SHALL снова дедуплицироваться +на эту задачу: активность задачи выводится только из её `state`, отдельный +восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в +`failed`/`stuck` тем же infohash (любым из хешей задачи) уже завладела +другая активная задача (новый приём, пока эта лежала упавшей), система +SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`, +сохраняя инвариант «не более одной активной задачи на infohash». + +`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не +рабочим механизмом. Две страховочные меры при этом РАЗНЫЕ: `magnet_timeout` +SHALL мериться по **возрасту** торрента (время от добавления в qBittorrent, +`added_on`, с фолбэком на `created_at` задачи), а `stuck_after` — по +**длительности простоя** (время от `last_activity` qBittorrent — момента +последнего движения данных), а НЕ по возрасту. Пока торрент в +`metaDL`/`forcedMetaDL` или иным образом прогрессирует в пределах +страховочного таймаута, задача в `failed`/`stuck` из-за него оказаться +SHALL NOT; в частности, торрент со свежим `last_activity` в `stuck` система +пометить SHALL NOT, даже если его общий возраст превышает `stuck_after` (см. +требование о терпеливости к долгим метаданным и меры таймаутов в +`docs/specs/workflow.md`). + +#### Scenario: Метаданные пришли после magnet_timeout + +- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент + в qBittorrent уже получил метаданные и качается (`downloading`) +- **WHEN** срабатывает фоновая сверка +- **THEN** задача возвращается в `downloading` +- **AND** повторный приём того же infohash снова дедуплицируется на неё + +#### Scenario: Торрент уже завершился, пока задача была в failed + +- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент + в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`) +- **WHEN** срабатывает фоновая сверка +- **THEN** задача переходит в `completed` и продолжает обычный поток + (распознавание/раскладка) + +#### Scenario: Источник так и не ожил — состояние не меняется + +- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент + всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent) +- **WHEN** срабатывает фоновая сверка +- **THEN** задача остаётся в `failed` + +#### Scenario: infohash уже занят другой активной задачей + +- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже + владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей) +- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка + пытается воскресить #1 +- **THEN** #1 остаётся в `failed` (восстановление не выполняется) +- **AND** активной по этому infohash остаётся #2 + +#### Scenario: Долго качавшийся торрент на миг зашёл в stalledDL + +- **GIVEN** торрент качался часами и двигал данные только что (свежий + `last_activity`), но на текущем тике qBittorrent показывает его `stalledDL` +- **WHEN** `worker` проверяет таймаут зависания +- **THEN** задача остаётся в `downloading` (простой меньше `stuck_after`), + несмотря на большой возраст торрента +- **AND** ложного `stuck` со «stalled for <возраст>» и уведомления о падении + не возникает + +### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов + +Система SHALL предоставлять пользователю команду повторной попытки (retry) +для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API). +Retry SHALL переводить задачу обратно в `downloading`, не вызывая её +немедленного повторного падения по таймауту: базис отсчёта таймаутов SHALL +сбрасываться. + +Сброс базиса система SHALL выполнять сохранением времени retry в поле задачи +(`retried_at`, RFC 3339 UTC), которое приподнимает пол ОБОИХ страховочных мер +(`magnet_timeout` по возрасту и `stuck_after` по простою): отсчёт ведётся от +`max(базис, retried_at)`. `retried_at` SHALL храниться в задаче (не в памяти +процесса), чтобы сброс базиса пережил интервал поллинга и рестарт процесса. +Благодаря этому даже живой, но давно добавленный либо давно простаивающий +торрент после retry SHALL получать свежее окно и на ближайшем тике сверки +падать снова SHALL NOT. + +Если источник задачи уже жив и ЗДОРОВ в qBittorrent, retry SHALL перецепляться +к существующему торренту, а не добавлять источник повторно вслепую. Если же +живой торрент в состоянии ошибки qBittorrent (`error`/`missingFiles`), retry +перецепляться к нему SHALL NOT (перецепка к сломанному торренту тут же вернула +бы задачу в `failed` по сверке) и SHALL повторно отдать источник, как при +отсутствии раздачи. Повторный `Add` выполняется, только когда раздачи в +qBittorrent нет ЛИБО она сломана. + +Повторный `Add` при retry система SHALL выполнять **по типу источника** +(`source_type`), как и добавление пойманной загрузки (см. `download-tracking` +«Добавление пойманной загрузки в qBittorrent»): magnet/url — ссылкой; torrent — +сохранёнными байтами `.torrent` файлом. Для torrent-источника retry БЕЗ живой +раздачи система SHALL добавлять раздачу байтами и SHALL NOT активировать задачу +в `downloading`, не добавив её (иначе задача повиснет как «нет в qBittorrent»). + +#### Scenario: Retry упавшей magnet-загрузки из веб-UI + +- **GIVEN** задача в `failed`, её торрент жив и здоров в qBittorrent +- **WHEN** пользователь нажимает retry в веб-UI +- **THEN** задача возвращается в `downloading` без повторного `Add` +- **AND** не падает снова на ближайшем тике сверки по таймауту + +#### Scenario: Retry живого, но давно простаивающего торрента не падает снова + +- **GIVEN** задача в `stuck`/`stalled`, её торрент жив в qBittorrent, но + добавлен давно и данные не двигались дольше `stuck_after` +- **WHEN** пользователь нажимает retry +- **THEN** задача возвращается в `downloading` без повторного `Add` +- **AND** на ближайшем тике сверки НЕ падает снова в `stuck` (базис сброшен + через `retried_at`) + +#### Scenario: Retry доступен в Telegram + +- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в + Telegram-боте +- **THEN** задача возвращается в `downloading` + +#### Scenario: Retry без живого источника добавляет источник заново + +- **GIVEN** задача в `failed`, раздачи в qBittorrent нет +- **WHEN** пользователь инициирует retry +- **THEN** источник добавляется в qBittorrent заново — magnet/url ссылкой, + torrent сохранёнными байтами файлом +- **AND** задача переходит в `downloading` + +#### Scenario: Retry сломанного живого торрента повторно отдаёт источник + +- **GIVEN** задача в `failed`, её торрент присутствует в qBittorrent, но в + состоянии ошибки (`error`/`missingFiles`) +- **WHEN** пользователь инициирует retry +- **THEN** источник отдаётся заново (перецепка к сломанному торренту не + выполняется) +- **AND** задача переходит в `downloading` + +#### Scenario: Retry torrent-загрузки без живого источника + +- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent + нет, байты `.torrent` сохранены +- **WHEN** пользователь инициирует retry +- **THEN** сохранённые байты добавляются в qBittorrent файлом +- **AND** задача переходит в `downloading` (не остаётся без раздачи) diff --git a/openspec/changes/retry-stall-basis/tasks.md b/openspec/changes/retry-stall-basis/tasks.md new file mode 100644 index 0000000..c8bc4ff --- /dev/null +++ b/openspec/changes/retry-stall-basis/tasks.md @@ -0,0 +1,44 @@ +# Tasks: retry-stall-basis + +## Схема и хранилище + +- [x] Миграция `0010_retried_at.sql` — колонка `download.retried_at` (nullable TEXT). +- [x] `store.Download.RetriedAt` (`db:"retried_at"`) + метод `RetriedTime()`. +- [x] `store.SetRetriedAt(ctx, id, t)` — перезаписывающая запись базиса retry. +- [x] Обновить ER-схему `docs/specs/database.md` (строка `retried_at`). + +## qBittorrent-клиент + +- [x] `qbt.Torrent.LastActivity` (`json:"last_activity"`). + +## Логика воркера + +- [x] `checkTimeouts` — две разные меры: `magnet_timeout` по возрасту, + `stuck_after` по простою (`last_activity`). +- [x] Разбить `torrentAge` на `addedBasis` (возраст, фолбэк `created_at`), + `stallDuration` (простой от `last_activity`), `retriedFloor` (пол по + `retried_at`). +- [x] `Retry` — сброс базиса через `SetRetriedAt`; перецепка только к здоровому + живому торренту, сломанный (`classErrored`) → повторный `Add` (NIT-12). +- [x] `Store` interface воркера — метод `SetRetriedAt`. + +## Спеки + +- [x] Дельта `state-reconciliation` — MODIFIED «Восстановление зависшей + загрузки» (простой от `last_activity`) и «Ручной повтор» (сброс базиса, + NIT-12). +- [x] `docs/specs/workflow.md` — устранить противоречие «возраст vs простой», + описать сброс базиса и перецепку только к здоровому торренту. + +## Тесты + +- [x] `TestRetryResetsTimeoutBasis` — следующий тик после retry (MAJOR-1). +- [x] `TestStallMeasuredFromLastActivity` — простой vs возраст (MAJOR-2). +- [x] `TestSetRetriedAtOverwrites` — store. +- [x] Обновить фейки (`fakeStore`, `memStore`) методом `SetRetriedAt`. + +## Проверки + +- [x] `openspec validate --strict retry-stall-basis`. +- [x] `task test`. +- [x] `task lint`.