## Why Два связанных бага в семантике таймаутов зависания и ручного retry делают повседневные сценарии сломанными: - **MAJOR-1 — retry живого торрента мгновенно снова падает.** `Worker.Retry` при живой раздаче (`alive=true`) не переиздаёт `Add`, а лишь возвращает задачу в `downloading`. Базис отсчёта таймаута (`age = now − added_on`) при этом НЕ сбрасывается. Если торрент давно добавлен/давно простаивает, ближайший тик снова видит `stalledDL && age > stuck_after` → задача опять уходит в `stuck` (~секунды). Спека `state-reconciliation` «Ручной повтор» требует сброса базиса, но код его не выполняет (комментарий «базис от added_on» верен лишь для ветки повторного `Add`). Существующий тест `TestRetryReattachesNoReadd` прячет баг, ставя `added_on` «минуту назад». - **MAJOR-2 — `stuck_after` мерит ВОЗРАСТ, а не ПРОСТОЙ.** `checkTimeouts` считает `stalledDL`-таймаут от `added_on` (возраст торрента). Торрент, качавшийся 5 часов и на один тик зашедший в `stalledDL` (нормальный проход между пирами), мгновенно получает `stuck` со лживым сообщением «stalled for 5h» и уведомление `EventFailed`. Результат — флап `stuck`↔`downloading` и до-часовые ложные пинги. Спека сама противоречива: «`stalledDL` дольше `stuck_after`» (простой) против «возраст от `added_on`». Дополнительно закрываются два NIT из того же ревью: - **NIT-10** — фолбэк базиса возраста `added_on → created_at` (когда qBit не отдал `added_on`) остаётся, но теперь явно документирован и покрыт. - **NIT-12** — retry задачи в `qbit_error` мгновенно откатывается: перецепка к сломанному (`error`/`missingFiles`) живому торренту бессмысленна — reconcile тут же возвращает задачу в `failed`. Retry перестаёт перецепляться к сломанному торренту и повторно отдаёт источник. ## What Changes - **Мера простоя вместо возраста для `stuck_after`.** `stalledDL`-таймаут считается от `last_activity` qBittorrent (момент последнего движения данных), а не от возраста торрента. Долго качавшийся торрент со свежей активностью в `stuck` не уходит (MAJOR-2). `magnet_timeout` по-прежнему мерит **возраст** (`metaDL` без метаданных) от `added_on` — это семантически верно. - **Сброс базиса таймаутов при ручном retry.** Новая колонка `download.retried_at` (RFC 3339 UTC) фиксирует момент retry и приподнимает базис ОБОИХ таймаутов (`max(базис, retried_at)`). После retry задача получает свежее окно и не падает снова на ближайшем тике (MAJOR-1). Хранится в БД (не в памяти), чтобы сброс пережил интервал поллинга и рестарт процесса. - **Retry не перецепляется к сломанному торренту.** Если живой торрент в состоянии ошибки qBittorrent (`error`/`missingFiles`), retry повторно отдаёт источник вместо перецепки (NIT-12). ## Capabilities ### New Capabilities Нет. Семантика таймаутов зависания относится к жизненному циклу загрузки, который пока живёт в `docs/specs/workflow.md` (не мигрирован в OpenSpec). Нормативная правка `stuck_after`/`magnet_timeout` вносится туда; в OpenSpec затрагивается только `state-reconciliation` (восстановление и ручной retry). ### Modified Capabilities - `state-reconciliation`: уточняется, что предотвращение `stuck` для прогрессирующего торрента опирается на **простой от `last_activity`**, а не на возраст; ручной retry сбрасывает базис таймаутов через `retried_at` и не перецепляется к сломанному живому торренту. ## Impact - **Спеки:** дельта `state-reconciliation` (2 MODIFIED requirements); правка семантики таймаутов и retry в `docs/specs/workflow.md` (источник истины по жизненному циклу до миграции). - **Код:** `internal/worker/worker.go` — `checkTimeouts` (две разные меры), `torrentAge`/новые `stallDuration`/`addedBasis`/`retriedFloor`, `Retry` (сброс базиса + перецепка только к здоровому торренту); `internal/qbt` (поле `last_activity`); `internal/store/download.go` (`RetriedAt`, `RetriedTime`, `SetRetriedAt`). - **Миграции БД:** `0010_retried_at.sql` — колонка `download.retried_at`; обновление ER-схемы `docs/specs/database.md`. - **qBittorrent-клиент:** читается новое поле `last_activity` из того же ответа `/torrents/info` (без нового вызова API).