Восстановление зависших загрузок и уведомления о падении (state-reconciliation)
Долгий metaDL больше не убивается агрессивным таймаутом: дефолт magnet_timeout 30m → 24h (страховочный предохранитель), базис отсчёта — added_on из qBittorrent, а не created_at (переживает retry/усыновление). Авто-восстановление: фоновая сверка возвращает в поток задачи, упавшие по нашей нетерпеливости (magnet_timeout/stalled), когда источник ожил и продвинулся за условие падения (downloading/completed по статусу торрента); qbit_error не воскрешается. Конфликт idempotency (infohash занят другой активной задачей) — оставляем в failed. Уведомления: любой переход в failed/stuck пингует автора (включая приёмный qbit_add через ingest), с дебаунсом против спама при флаппинге stalled. Ручной retry добавлен в веб-UI и Telegram; Retry перецепляется к живому торренту вместо слепого Add. Дельта state-reconciliation влита в живые спеки; обновлён workflow.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-30
|
||||
@@ -0,0 +1,158 @@
|
||||
## 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` и логируем конфликт.
|
||||
- **Уведомление об успешном авто-восстановлении** → не шлём, достаточно
|
||||
лога.
|
||||
@@ -0,0 +1,75 @@
|
||||
## Why
|
||||
|
||||
Загрузка magnet'ом ушла в терминальный `failed`/`magnet_timeout` по
|
||||
wall-clock таймауту (возраст от `created_at`, ~1ч), хотя qBittorrent просто
|
||||
долго тянул метаданные (медленные трекеры / мало пиров). Метаданные в итоге
|
||||
пришли, торрент жив и качается, но задача застряла в терминальном состоянии
|
||||
без выхода — восстановить её нельзя. Вдобавок падение происходит молча (нет
|
||||
уведомления автору), а единственный путь возврата `Worker.Retry` баговый
|
||||
(не сбрасывает базис времени → задача мгновенно снова падает) и доступен
|
||||
только через REST, но не из веб-UI и Telegram.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Терпеливость к `metaDL`.** Перестаём убивать долгий magnet агрессивным
|
||||
таймаутом. Дефолт `[worker].magnet_timeout` поднимается до `24h` — это
|
||||
редкий страховочный предохранитель, а не рабочий механизм. Настоящие
|
||||
провалы определяются по статусам ошибок qBittorrent (`error`/`missingFiles`
|
||||
→ `qbit_error`), а не по wall-clock. *(У qBittorrent нет статуса «magnet
|
||||
мёртв» — зависший magnet вечно висит в `metaDL`, поэтому единственный
|
||||
сигнал на этот кейс — большой страховочный таймаут.)*
|
||||
- **Базис таймаута — от факта, а не от `created_at`.** Возраст для
|
||||
`magnet_timeout`/`stalled` считаем от времени добавления торрента в
|
||||
qBittorrent (`added_on`), а не от создания записи. Это чинит неверный
|
||||
отсчёт для усыновлённых раздач и устраняет мгновенное повторное падение
|
||||
после возврата в `downloading`.
|
||||
- **Уведомление о любом падении.** Переход в `failed` (любой `error_code`)
|
||||
и `stuck` уведомляет автора загрузки через `notifier` (раньше уведомления
|
||||
слались только для `review`/`done`/`orphaned`/`target_missing`).
|
||||
- **Авто-восстановление из `failed`/`stuck`.** Фоновая сверка
|
||||
(state-reconciliation) замечает, что у задачи в восстановимом
|
||||
`failed`/`stuck` (наша нетерпеливость: `magnet_timeout`, `stalled`)
|
||||
источник в qBittorrent жив и продвинулся, и возвращает задачу в поток
|
||||
(`downloading` либо `completed` по статусу торрента). Пользовательские
|
||||
и реальные провалы (`qbit_error`, `reverted`, `cancelled`) сверка не
|
||||
воскрешает.
|
||||
- **Ручной retry из UI и Telegram.** Кнопка повторной попытки добавляется в
|
||||
веб-UI и Telegram-бот (раньше — только Cancel; retry был только в REST).
|
||||
`Worker.Retry` чинится: перецепляется к уже живому торренту вместо слепого
|
||||
повторного `Add`, базис таймаута сбрасывается.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет. Уведомления о падении и семантика таймаута относятся к жизненному циклу
|
||||
загрузки, который пока живёт в `docs/specs/workflow.md` (ещё не мигрирован в
|
||||
OpenSpec); заводить отдельную capability `notifications` сейчас —
|
||||
преждевременное дробление.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `state-reconciliation`: восстановимые `failed`/`stuck` (`magnet_timeout`,
|
||||
`stalled`) перестают быть «неприкосновенными» для сверки и подлежат
|
||||
авто-восстановлению при живом продвинувшемся источнике; добавляется
|
||||
требование о восстановлении и о доступности ручного retry. `qbit_error`,
|
||||
`reverted`, `cancelled`, `deleted` остаются вне восстановления.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Спеки:** дельта `state-reconciliation`; обновление графа переходов и
|
||||
семантики таймаута/уведомлений в `docs/specs/workflow.md` (источник истины
|
||||
по жизненному циклу до миграции).
|
||||
- **Конфиг:** дефолт `[worker].magnet_timeout` → `24h`
|
||||
(`internal/config/config.go`), `config.example.toml`.
|
||||
- **Код:** `internal/worker/worker.go` — `transition` (уведомление о
|
||||
failed/stuck), `checkTimeouts` (базис от `added_on`), `reconcile`/
|
||||
`reconcileDesync` (воскрешение из failed/stuck), `Retry` (перецепление +
|
||||
сброс базиса); новый `Event` падения и его обработка в `notifier`/
|
||||
`tgbot`/`httpapi`; кнопка retry в `internal/httpapi` и `internal/tgbot`;
|
||||
вынос строки `"magnet_timeout"` (и смежных `error_code`) в именованные
|
||||
константы рядом с состояниями.
|
||||
- **qBittorrent-клиент:** возможно потребуется поле `added_on` в
|
||||
`qbt.Torrent` (если ещё не читается).
|
||||
- **Миграции БД:** не ожидаются (восстановление опирается на состояние qBit и
|
||||
существующие поля задачи).
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Периодическая сверка состояния с реальностью
|
||||
|
||||
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
|
||||
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
|
||||
выводить состояние задачи из двух независимых признаков: присутствия
|
||||
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
|
||||
присутствия **цели** (см. требование о владении целевым путём: существуют все
|
||||
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
|
||||
загрузке).
|
||||
|
||||
Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
|
||||
`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
|
||||
оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/
|
||||
`linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния
|
||||
сверка по матрице трогать SHALL NOT.
|
||||
|
||||
**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или
|
||||
`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой)
|
||||
сверка SHALL рассматривать отдельно — на предмет оживления источника (см.
|
||||
требование о восстановлении зависшей загрузки), не по матрице «источник ×
|
||||
цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT.
|
||||
|
||||
Состояние SHALL переписываться только при его изменении (без записи и логов,
|
||||
когда выведенное состояние совпадает с текущим).
|
||||
|
||||
#### Scenario: Источник и цель на месте — состояние не меняется
|
||||
|
||||
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
|
||||
разложенные хардлинки существуют
|
||||
- **THEN** задача остаётся в `done`
|
||||
- **AND** запись состояния и лог перехода не выполняются
|
||||
|
||||
#### Scenario: Частичная пропажа цели считается отсутствием
|
||||
|
||||
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
|
||||
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
|
||||
|
||||
#### Scenario: Задача в deleted сверкой не переоценивается
|
||||
|
||||
- **WHEN** задача находится в `deleted`
|
||||
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
|
||||
бывшему пути появился файл другой загрузки
|
||||
|
||||
#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит
|
||||
|
||||
- **WHEN** задача в `failed` с `error_code` `qbit_error`
|
||||
- **THEN** сверка её не рассматривает и состояние не меняет
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Восстановление зависшей загрузки при оживлении источника
|
||||
|
||||
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
|
||||
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
|
||||
источник в qBittorrent жив и продвинулся: переход выводится из текущего
|
||||
состояния торрента так же, как при штатной сверке загрузки
|
||||
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
|
||||
`downloading`). Восстановление SHALL опираться на фактическое состояние
|
||||
торрента в qBittorrent, а не на время с момента создания записи.
|
||||
|
||||
При возврате в любое нетерминальное состояние (`downloading` или
|
||||
`completed`) система SHALL восстанавливать идемпотентность задачи
|
||||
(`idempotency_key`), чтобы повторный приём того же infohash снова
|
||||
дедуплицировался на эту задачу. Если за время простоя в `failed`/`stuck` тем
|
||||
же infohash уже завладела другая активная задача (ключ снимается при падении и
|
||||
мог быть перехвачен новым приёмом), система SHALL NOT воскрешать упавшую
|
||||
задачу и SHALL оставить её в `failed`/`stuck`, сохраняя инвариант «не более
|
||||
одной активной задачи на infohash».
|
||||
|
||||
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
|
||||
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
|
||||
прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck`
|
||||
из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим
|
||||
метаданным в `docs/specs/workflow.md`).
|
||||
|
||||
#### Scenario: Метаданные пришли после magnet_timeout
|
||||
|
||||
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
|
||||
в qBittorrent уже получил метаданные и качается (`downloading`)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача возвращается в `downloading`
|
||||
- **AND** её `idempotency_key` восстанавливается
|
||||
|
||||
#### Scenario: Торрент уже завершился, пока задача была в failed
|
||||
|
||||
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
|
||||
в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача переходит в `completed` и продолжает обычный поток
|
||||
(распознавание/раскладка)
|
||||
|
||||
#### Scenario: Источник так и не ожил — состояние не меняется
|
||||
|
||||
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
|
||||
всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача остаётся в `failed`
|
||||
|
||||
#### Scenario: infohash уже занят другой активной задачей
|
||||
|
||||
- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже
|
||||
владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей)
|
||||
- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка
|
||||
пытается воскресить #1
|
||||
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
|
||||
- **AND** активной по этому infohash остаётся #2
|
||||
|
||||
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
|
||||
|
||||
Система SHALL предоставлять пользователю команду повторной попытки (retry)
|
||||
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
|
||||
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
|
||||
немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL
|
||||
сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого
|
||||
`created_at`).
|
||||
|
||||
Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к
|
||||
существующему торренту, а не добавлять источник повторно вслепую; повторный
|
||||
`Add` выполняется, только когда раздачи в qBittorrent нет.
|
||||
|
||||
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
|
||||
|
||||
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
|
||||
- **WHEN** пользователь нажимает retry в веб-UI
|
||||
- **THEN** задача возвращается в `downloading` без повторного `Add`
|
||||
- **AND** не падает снова на ближайшем тике сверки по таймауту
|
||||
|
||||
#### Scenario: Retry доступен в Telegram
|
||||
|
||||
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
|
||||
Telegram-боте
|
||||
- **THEN** задача возвращается в `downloading`
|
||||
|
||||
#### Scenario: Retry без живого источника добавляет торрент заново
|
||||
|
||||
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
|
||||
- **WHEN** пользователь инициирует retry
|
||||
- **THEN** источник (magnet) добавляется в qBittorrent заново
|
||||
- **AND** задача переходит в `downloading`
|
||||
@@ -0,0 +1,76 @@
|
||||
## 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`
|
||||
Reference in New Issue
Block a user