## 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` и логируем конфликт. - **Уведомление об успешном авто-восстановлении** → не шлём, достаточно лога.