Итог параллельной волны фиксов (worktree-изоляция, cherry-pick в master): - ingest-dedup-integrity (F1, F6) → спека ingest - retry-stall-basis (MAJOR-1, MAJOR-2) → спека state-reconciliation - linking-transition-robustness (MAJOR-4, MINOR-7) → спеки file-layout и state-reconciliation Дельты влиты в openspec/specs, changes перенесены в openspec/changes/archive/2026-07-08-*. Беклог не трогаю (по решению). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
79 lines
6.1 KiB
Markdown
79 lines
6.1 KiB
Markdown
## 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).
|