Восстановление зависших загрузок и уведомления о падении (state-reconciliation)

Долгий metaDL больше не убивается агрессивным таймаутом: дефолт
magnet_timeout 30m → 24h (страховочный предохранитель), базис отсчёта —
added_on из qBittorrent, а не created_at (переживает retry/усыновление).

Авто-восстановление: фоновая сверка возвращает в поток задачи, упавшие по
нашей нетерпеливости (magnet_timeout/stalled), когда источник ожил и
продвинулся за условие падения (downloading/completed по статусу торрента);
qbit_error не воскрешается. Конфликт idempotency (infohash занят другой
активной задачей) — оставляем в failed.

Уведомления: любой переход в failed/stuck пингует автора (включая приёмный
qbit_add через ingest), с дебаунсом против спама при флаппинге stalled.
Ручной retry добавлен в веб-UI и Telegram; Retry перецепляется к живому
торренту вместо слепого Add.

Дельта state-reconciliation влита в живые спеки; обновлён workflow.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
av
2026-06-30 14:51:57 +03:00
co-authored by Claude Opus 4.8
parent dfa182a5a9
commit 70d8758646
24 changed files with 1168 additions and 40 deletions
@@ -0,0 +1,76 @@
## 1. Константы и базис таймаута
- [x] 1.1 Вынести `error_code`-строки (`magnet_timeout`, `stalled`,
`qbit_error` — в `worker`; `qbit_add` — в `ingest`) в именованные константы;
`error_code` уже читается из `store.Download.ErrorCode` (миграция не нужна)
- [x] 1.2 В `checkTimeouts` считать возраст от `torrent.AddedOn` (UTC) через
хелпер `torrentAge` (фолбэк на `created_at`, если `added_on` нет)
- [x] 1.3 Поднять дефолт `[worker].magnet_timeout` до `24h` в
`internal/config/config.go` и `config.example.toml` (с пометкой про страховку)
## 2. Уведомление о падении
- [x] 2.1 Добавить `EventFailed` в `NotifyEvent` (worker.go)
- [x] 2.2 В `transition` слать `EventFailed` при переходе в `StateFailed` и
`StateStuck` (неблокирующе, вне `w.mu`)
- [x] 2.3 Обработать `EventFailed` в Telegram (`renderFailed` + `retryKeyboard`);
httpapi `Notifier` не реализует — только tgbot
## 3. Авто-восстановление в сверке
- [x] 3.1 Восстановление `idempotency_key` отдельным методом стора НЕ нужно:
`SetDownloadState` сам ставит ключ в `infohash` для нетерминального состояния
- [x] 3.2 Реализовать `reconcileRecovery` (под `w.mu`, из `Poll`): задачи в
`failed`/`stuck` с `error_code` `magnet_timeout`/`stalled`, поиск в `byHash`
- [x] 3.3 Воскрешать только при прогрессе торрента (`torrentProgressed`):
`magnet_timeout``!isMeta`; `stalled``!isStalledDL`; не `classErrored`;
целевое состояние из `classify` (`recoveredState`: ready → `completed`,
downloading → `downloading`)
- [x] 3.4 Конфликт занятого `idempotency_key`: пре-проверка через
`FindActiveByInfohash` — оставляем в `failed` + лог
- [x] 3.5 Тесты (`recovery_test.go`): метаданные → `downloading`; готов →
`completed`; всё ещё `metaDL``failed`; `qbit_error`/ошибка не воскрешаются;
нет источника → `failed`; конфликт ключа → `failed`
## 4. Починка Retry и ручной retry в транспортах
- [x] 4.1 `Worker.Retry`: при живом торренте — без повторного `Add`, только
`downloading`; `Add` лишь когда раздачи нет
- [x] 4.2 Тест: retry при живом торренте не делает `Add` и не падает на
ближайшем тике (базис `added_on`)
- [x] 4.3 Кнопка retry в веб-UI (`internal/httpapi` + `index.html`) для
`failed`/`stuck`; роут `/ui/downloads/{id}/retry`; тест `TestUIRetry`
- [x] 4.4 Действие retry в Telegram-боте (колбэк `retry:` + кнопка); тесты
`TestBot_CallbackRetry`, `TestBot_NotifyFailed`
## 5. Документация спек
- [x] 5.1 Обновить `docs/specs/workflow.md`: `magnet_timeout` как страховка,
базис `added_on`, уведомление о `failed`/`stuck`, восстановление и retry
- [x] 5.2 `openspec validate --strict download-failure-recovery` — без ошибок
## 6. Проверка
- [x] 6.1 `task lint` (0 issues) и `task test` (зелёные)
- [x] 6.2 Ревью кода (чекпоинт до archive) — 8-угловой multi-agent проход
## 7. Фиксы по ревью кода
- [x] 7.1 Конфликт `idempotency_key`: проверка `FindActiveByInfohash`
распространена на ветку `completed` (а не только `downloading`) —
иначе constraint-ошибка и зависание в `failed` с логом каждый тик
(тест `TestRecoverySkipsConflictOnCompleted`)
- [x] 7.2 Recovery не пишет в `error_msg` здоровой задачи (передаём `""`,
причину — в лог), иначе заметка светилась бы как ошибка в UI/REST
- [x] 7.3 Эффективность: `reconcileRecovery` грузит кандидатов через
`store.ListRecoverable` (SQL-фильтр по `error_code`), а не вычитывает все
failed/stuck каждый тик
- [x] 7.4 Дебаунс уведомлений о падении (`shouldNotifyFail`, окно 1h) —
мерцающий stalled-торрент не спамит `EventFailed` (тест
`TestFailNotifyDebounce`)
- [x] 7.5 Уведомление о падении `qbit_add` в ingest через closure
(`SetFailureNotifier`, wiring в serve.go), тест
`TestIngestQbitErrorNotifies`
- [x] 7.6 Конвенция логирования: убран неймспейс-префикс `recovery:` из `msg`
- [x] 7.7 Мелочи: дедуп ветки `renderCard`, диагностика в `torrentAge`,
`time.Unix(...).UTC()`, актуализирован комментарий `terminalStates`