## Context

Машина состояний загрузок живёт в `internal/worker/worker.go`
(`Poll`/`reconcile`/`checkTimeouts`/`transition`/`Retry`), список состояний и
терминальность — в `internal/store/download.go`. Граф переходов описан в
`docs/specs/workflow.md` (ещё не мигрирован в OpenSpec — он остаётся
источником истины по жизненному циклу). Сверка реальности с БД (capability
`state-reconciliation`) реализована в `reconcileDesync` и уже исключает
`failed`/`stuck`.

Текущее поведение, породившее инцидент:

- `checkTimeouts` (worker.go:268-285) меряет возраст задачи от `created_at` и
  при `metaDL` дольше `magnet_timeout` гонит в `failed`/`magnet_timeout`.
  Дефолт `magnet_timeout` = 30m (`config.go:184`), но для долгих magnet это
  слишком агрессивно.
- `failed`/`stuck` терминальны, выхода нет; `transition` уведомляет только
  `review`/`done`/`orphaned`/`target_missing` (worker.go:301-312) — падение
  молчит.
- `Worker.Retry` (worker.go:348-373) возвращает в `downloading`, но базис
  таймаута (`created_at`) не меняется → `checkTimeouts` роняет задачу снова
  на ближайшем тике; retry экспонирован только в REST.

`qbt.Torrent` уже содержит `AddedOn` (unix, секунды) — время добавления
торрента в qBittorrent.

## Goals / Non-Goals

**Goals:**

- Долгий `metaDL` не убивается агрессивно; `magnet_timeout` — редкий
  страховочный предохранитель (дефолт 24h), а не рабочий механизм.
- Корректный базис таймаута — от факта в qBittorrent (`added_on`), не от
  `created_at`.
- Любой переход в `failed`/`stuck` уведомляет автора.
- Авто-восстановление задач, упавших по нашей нетерпеливости
  (`magnet_timeout`/`stalled`), когда источник в qBittorrent ожил и
  продвинулся.
- Ручной retry в веб-UI и Telegram; перецепление к живому торренту вместо
  слепого повторного `Add`.

**Non-Goals:**

- Не воскрешаем реальные/пользовательские провалы: `qbit_error`, `reverted`,
  `cancelled`, `deleted`.
- Не трогаем сам торрент в qBittorrent при падении (источник
  неприкосновенен).
- Без миграций БД и без новых внешних зависимостей.
- Не вводим отдельную capability `notifications` — преждевременно.

## Decisions

### 1. Базис таймаута — `added_on`, а не `created_at`

`checkTimeouts` считает `age = now - torrent.AddedOn` (UTC). Это чинит
неверный отсчёт для усыновлённых раздач и — главное — делает retry/восстановление
устойчивым: после возврата в `downloading` базис не сбрасывается в «сейчас»,
он привязан к реальному возрасту торрента. Отдельный сброс `created_at` при
Retry больше не нужен.

*Альтернатива:* хранить «время входа в metaDL» отдельным полем БД — точнее,
но требует миграции и записи на каждый тик. `added_on` достаточно (огрубление
в большую сторону безопасно при 24h-предохранителе).

### 2. Дефолт `magnet_timeout` → 24h

Меняем дефолт в `config.go` и `config.example.toml`. Реальные провалы ловятся
классом `classErrored` (`error`/`missingFiles` → `qbit_error`) — это уже
работает и не зависит от wall-clock. У qBittorrent нет статуса «magnet мёртв»,
поэтому большой страховочный таймаут — единственный сигнал на безнадёжный
magnet.

### 3. Уведомление о падении

В `transition` добавляем ветки для `StateFailed` и `StateStuck` → новый
`EventFailed`. Сообщение в `notifier` (tgbot/httpapi) читает состояние и
`error_code` задачи и формирует текст. Один `Event` на оба состояния —
дробить на `EventStuck` смысла нет (различие видно из `error_code`).

### 4. Авто-восстановление в сверке

Отдельный проход `reconcileRecovery` (рядом с `reconcileDesync`, под `w.mu`,
из `Poll`): берём задачи в `failed`/`stuck` с восстановимым `error_code`
(`magnet_timeout`/`stalled`), находим их торрент в уже построенном индексе
`byHash`. Воскрешаем **только если торрент продвинулся за условие падения**:

- `magnet_timeout`: восстанавливаем, когда `!isMeta(state)` и не `classErrored`
  (метаданные получены);
- `stalled`: восстанавливаем, когда `!isStalledDL(state)` и не `classErrored`
  (раздача ожила).

Иначе (торрент всё ещё в `metaDL`/`stalledDL`, либо отсутствует) — оставляем
как есть. Это **критично против зацикливания**: при 24h-предохранителе мёртвый
magnet, упавший по таймауту, остаётся в `metaDL`; без проверки прогресса
восстановление вернуло бы его в `downloading`, и он падал бы снова каждые 24ч.

Целевое состояние выводим из `classify(state)`: `classReady` → `completed`,
`classDownloading` → `downloading`. При возврате в `downloading`
восстанавливаем `idempotency_key = infohash` (нужен метод стора, т.к.
`SetDownloadState` его при терминальном переходе снимает), чтобы повторный
приём снова дедуплицировался.

*Альтернатива:* расширить `reconcileDesync` матрицей «источник × цель». Не
подходит: у `failed`/`stuck` нет разложенной цели, ось другая (прогресс
источника), отдельный проход чище.

### 5. Починка `Worker.Retry` + кнопки в UI/Telegram

`Retry`: если торрент задачи уже есть в qBittorrent (живой) — не делаем
повторный `Add`, только переводим в `downloading` и восстанавливаем
`idempotency_key`; `Add` выполняем, только когда раздачи нет. Базис таймаута
теперь `added_on`, поэтому немедленного повторного падения нет (корень бага
устранён решением 1). В `internal/httpapi` (веб-UI) и `internal/tgbot`
добавляем действие retry рядом с существующим Cancel, вызывающее тот же
`Worker.Retry`.

### 6. `error_code` в именованные константы

Строки `"magnet_timeout"`, `"stalled"`, `"qbit_error"`, `"qbit_add"` выносим в
именованные константы (рядом с состояниями в `store` или в `worker`), чтобы
проверка восстановимости (`magnet_timeout`/`stalled`) и присвоение не
расходились по литералам. Требует, чтобы `error_code` задачи был доступен из
`store.Download` (проверить наличие поля; при отсутствии — добавить чтение,
без миграции, столбец уже есть).

## Risks / Trade-offs

- **Флаппинг уведомлений** (fail → восстановление → fail) → при дефолте 24h и
  условии «торрент продвинулся» падение и воскрешение редки; повторный fail
  возможен только если раздача снова реально застрянет. Доп. дебаунс не
  вводим — усложнение без явной нужды.
- **Базис `added_on` огрубляет** (re-add торрента сбрасывает возраст) → при
  24h-предохранителе и авто-восстановлении это не приводит к ложным провалам;
  ранее проблема была в 30m-агрессии, которую и убираем.
- **`magnet_timeout` всё ещё может ложно сработать** на очень медленном, но
  живом magnet (>24h до метаданных) → теперь это не тупик: уведомление + при
  получении метаданных авто-восстановление вернёт задачу, плюс есть ручной
  retry.
- **`idempotency_key` восстановление** при воскрешении: если за время в
  `failed` пользователь успел повторно принять тот же infohash и завести
  новую задачу, ключ уже занят. Обрабатываем как конфликт (не воскрешаем
  старую либо логируем и оставляем в failed) — уточнить в реализации.

## Migration Plan

Изменение поведения + конфига, без миграций БД и без слома API. Деплой —
обычный (новый бинарь на umbar). Дефолт `magnet_timeout` меняется; явное
значение в существующем `config.toml` сохраняет поведение пользователя.
Откат — предыдущий бинарь; данные совместимы.

## Open Questions

Решены на ревью дизайна:

- **Занятый `idempotency_key` при авто-восстановлении** → старую задачу не
  воскрешаем, оставляем в `failed` и логируем конфликт.
- **Уведомление об успешном авто-восстановлении** → не шлём, достаточно
  лога.
