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