Итог параллельной волны фиксов (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>
6.1 KiB
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_activityqBittorrent (момент последнего движения данных), а не от возраста торрента. Долго качавшийся торрент со свежей активностью в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).