## 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`