OpenSpec: архивация трёх параллельных changes + синк спек
Итог параллельной волны фиксов (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>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-08
|
||||
@@ -0,0 +1,104 @@
|
||||
# Design: retry/stall basis
|
||||
|
||||
## Контекст
|
||||
|
||||
Два таймаута зависания в `Worker.checkTimeouts` сегодня используют один базис —
|
||||
возраст торрента `now − added_on`:
|
||||
|
||||
- `magnet_timeout`: `metaDL` дольше порога → `failed/magnet_timeout`.
|
||||
- `stuck_after`: `stalledDL` дольше порога → `stuck/stalled`.
|
||||
|
||||
Для `magnet_timeout` возраст семантически верен (сколько торрент вообще висит
|
||||
без метаданных). Для `stuck_after` возраст НЕВЕРЕН: нас интересует **простой**
|
||||
(сколько данные не двигаются), а не общий возраст (MAJOR-2). Отдельно retry
|
||||
живого торрента не сбрасывает базис, и задача мгновенно снова падает (MAJOR-1).
|
||||
|
||||
## Дизайн-развилка: откуда брать базис простоя/таймаута
|
||||
|
||||
Ключевое решение change — где взять базис для двух мер. Рассмотрены варианты:
|
||||
|
||||
- **(a) Новые колонки БД** `retried_at` и/или `stalled_since`. Базис возраста =
|
||||
`max(added_on, retried_at)`; простой — от `stalled_since` (момент входа в
|
||||
`stalledDL`, который мы сами детектируем и пишем/сбрасываем на каждом тике).
|
||||
- **(b) Переиспользовать `last_activity` из снимка торрента** для измерения
|
||||
простоя, без колонки на stall.
|
||||
- **(c) Гибрид (ВЫБРАН):** колонка `retried_at` (только для сброса базиса при
|
||||
ручном retry) + `last_activity` qBittorrent (для измерения простоя). Колонки
|
||||
`stalled_since` НЕТ.
|
||||
|
||||
### Выбор: (c) `retried_at` (БД) + `last_activity` (qBit)
|
||||
|
||||
**Простой мерим по `last_activity`, а не по `stalled_since`-колонке.**
|
||||
qBittorrent уже отдаёт `last_activity` (Unix-время последнего движения данных)
|
||||
в том же ответе `/torrents/info` — это авторитетный источник «сколько простой»
|
||||
прямо из движка. `stalled_since` дублировал бы это состояние, требовал бы
|
||||
детектировать переход «вход в stalledDL», писать/сбрасывать колонку на КАЖДОМ
|
||||
тике (торренты мерцают `stalledDL`↔`downloading`) и рисковал бы разъездом с
|
||||
собственным взглядом qBittorrent. Простой = `now − last_activity`: торрент,
|
||||
двигавший данные секунду назад, простаивает ~0 несмотря на возраст 5ч →
|
||||
MAJOR-2 закрыт без схемы для stall. Это часть варианта (b).
|
||||
|
||||
**Сброс базиса при retry храним в `retried_at` (БД), а не в памяти.** Спека
|
||||
требует, чтобы retry давал свежее окно и задача не падала снова. `last_activity`
|
||||
этого не выражает: у по-настоящему простаивающего торрента она «часы назад», и
|
||||
возврат в `downloading` тут же дал бы `stuck` на следующем тике. Нужна
|
||||
персистентная метка «пользователь нажал retry в момент T», которая приподнимает
|
||||
пол ОБОИХ базисов: `basis = max(добавление|last_activity, retried_at)`. Она
|
||||
должна пережить интервал поллинга и рестарт процесса (retry, затем рестарт не
|
||||
должен ронять задачу), поэтому — колонка, а не in-memory map. Это часть варианта
|
||||
(a), но минимальная: одна nullable TEXT-колонка. Закрывает MAJOR-1.
|
||||
|
||||
### Почему не чистые (a) или (b)
|
||||
|
||||
- **Чистый (b) без колонки** — нельзя записать `last_activity` qBittorrent, так
|
||||
что retry не смог бы сдвинуть базис → MAJOR-1 не решается.
|
||||
- **Чистый (a) со `stalled_since`** — лишняя колонка + пер-тиковая
|
||||
бухгалтерия входа/выхода из `stalledDL`, дублирующая `last_activity`. Отвергнут
|
||||
на минимальности схемы и единственном источнике истины.
|
||||
|
||||
### Замечание об интеграции (точка человеческого вето)
|
||||
|
||||
Change читает НОВОЕ поле qBittorrent `last_activity`, но НЕ добавляет нового
|
||||
вызова API или интеграционной поверхности — поле уже приходит в ответе
|
||||
`/torrents/info`, парсим на одно поле больше. Это единственная «интеграция»
|
||||
change, и она безопасна. Более глубокая интеграция для полного решения NIT-12
|
||||
(см. ниже) СОЗНАТЕЛЬНО отложена как точка человеческого вето.
|
||||
|
||||
## Итоговая схема базисов
|
||||
|
||||
```
|
||||
magnetAge = now − max( added_on | created_at(fallback), retried_at ) // magnet_timeout
|
||||
stallIdle = now − max( last_activity | added_on|created_at(fallback), retried_at ) // stuck_after
|
||||
```
|
||||
|
||||
- `addedBasis(d,t)` — `added_on`, иначе `created_at` (NIT-10), иначе базис
|
||||
неизвестен (WARN, таймаут не срабатывает).
|
||||
- `retriedFloor(d,basis)` — приподнимает базис до `retried_at`, если он позже.
|
||||
- `retried_at` не чистится: как только данные двинулись, `last_activity`
|
||||
естественно обгоняет `retried_at`, и пол перестаёт влиять.
|
||||
|
||||
## NIT-12: retry сломанного живого торрента
|
||||
|
||||
Живой торрент в `error`/`missingFiles` (класс `classErrored`) — перецепка к
|
||||
нему бессмысленна: reconcile на ближайшем тике вернёт задачу в `failed`. Retry
|
||||
теперь считает такой торрент «неживым для целей перецепки» и идёт по ветке
|
||||
повторного `Add` (повторно отдаёт источник). Это честнее слепой перецепки:
|
||||
retry перецепляется только к ЗДОРОВОМУ живому торренту.
|
||||
|
||||
**Остаточное ограничение (отложено, точка вето):** для устойчиво сломанного
|
||||
торрента повторный `Add` того же infohash qBittorrent, как правило, дедуплицирует
|
||||
— ошибка не очистится, и следующий тик всё равно вернёт задачу в `failed`. Полное
|
||||
устранение (принудительный recheck / delete+re-add через qBittorrent) требует
|
||||
НОВОЙ интеграции с клиентом и вынесено за рамки change на человеческое решение.
|
||||
|
||||
## Тесты
|
||||
|
||||
- `TestRetryResetsTimeoutBasis` — MAJOR-1: retry живого stalledDL-торрента с
|
||||
давним `added_on` и давним `last_activity`, затем СЛЕДУЮЩИЙ тик Poll →
|
||||
остаётся `downloading` (без сброса базиса ушёл бы в `stuck`). Именно эту
|
||||
регрессию прячет `TestRetryReattachesNoReadd`.
|
||||
- `TestStallMeasuredFromLastActivity` — MAJOR-2: `stalledDL` с давним `added_on`,
|
||||
но свежим `last_activity` → `downloading`; контроль — давняя `last_activity` →
|
||||
`stuck`.
|
||||
- `TestSetRetriedAtOverwrites` — store: `retried_at` перезаписывается (в отличие
|
||||
от однократного `source_added_at`).
|
||||
@@ -0,0 +1,78 @@
|
||||
## 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).
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Восстановление зависшей загрузки при оживлении источника
|
||||
|
||||
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
|
||||
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
|
||||
источник в qBittorrent жив и продвинулся: переход выводится из текущего
|
||||
состояния торрента так же, как при штатной сверке загрузки
|
||||
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
|
||||
`downloading`). Восстановление SHALL опираться на фактическое состояние
|
||||
торрента в qBittorrent, а не на время с момента создания записи.
|
||||
|
||||
После возврата в любое нетерминальное состояние (`downloading` или
|
||||
`completed`) повторный приём того же infohash SHALL снова дедуплицироваться
|
||||
на эту задачу: активность задачи выводится только из её `state`, отдельный
|
||||
восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в
|
||||
`failed`/`stuck` тем же infohash (любым из хешей задачи) уже завладела
|
||||
другая активная задача (новый приём, пока эта лежала упавшей), система
|
||||
SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`,
|
||||
сохраняя инвариант «не более одной активной задачи на infohash».
|
||||
|
||||
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
|
||||
рабочим механизмом. Две страховочные меры при этом РАЗНЫЕ: `magnet_timeout`
|
||||
SHALL мериться по **возрасту** торрента (время от добавления в qBittorrent,
|
||||
`added_on`, с фолбэком на `created_at` задачи), а `stuck_after` — по
|
||||
**длительности простоя** (время от `last_activity` qBittorrent — момента
|
||||
последнего движения данных), а НЕ по возрасту. Пока торрент в
|
||||
`metaDL`/`forcedMetaDL` или иным образом прогрессирует в пределах
|
||||
страховочного таймаута, задача в `failed`/`stuck` из-за него оказаться
|
||||
SHALL NOT; в частности, торрент со свежим `last_activity` в `stuck` система
|
||||
пометить SHALL NOT, даже если его общий возраст превышает `stuck_after` (см.
|
||||
требование о терпеливости к долгим метаданным и меры таймаутов в
|
||||
`docs/specs/workflow.md`).
|
||||
|
||||
#### Scenario: Метаданные пришли после magnet_timeout
|
||||
|
||||
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
|
||||
в qBittorrent уже получил метаданные и качается (`downloading`)
|
||||
- **WHEN** срабатывает фоновая сверка
|
||||
- **THEN** задача возвращается в `downloading`
|
||||
- **AND** повторный приём того же infohash снова дедуплицируется на неё
|
||||
|
||||
#### 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
|
||||
|
||||
#### Scenario: Долго качавшийся торрент на миг зашёл в stalledDL
|
||||
|
||||
- **GIVEN** торрент качался часами и двигал данные только что (свежий
|
||||
`last_activity`), но на текущем тике qBittorrent показывает его `stalledDL`
|
||||
- **WHEN** `worker` проверяет таймаут зависания
|
||||
- **THEN** задача остаётся в `downloading` (простой меньше `stuck_after`),
|
||||
несмотря на большой возраст торрента
|
||||
- **AND** ложного `stuck` со «stalled for <возраст>» и уведомления о падении
|
||||
не возникает
|
||||
|
||||
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
|
||||
|
||||
Система SHALL предоставлять пользователю команду повторной попытки (retry)
|
||||
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
|
||||
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
|
||||
немедленного повторного падения по таймауту: базис отсчёта таймаутов SHALL
|
||||
сбрасываться.
|
||||
|
||||
Сброс базиса система SHALL выполнять сохранением времени retry в поле задачи
|
||||
(`retried_at`, RFC 3339 UTC), которое приподнимает пол ОБОИХ страховочных мер
|
||||
(`magnet_timeout` по возрасту и `stuck_after` по простою): отсчёт ведётся от
|
||||
`max(базис, retried_at)`. `retried_at` SHALL храниться в задаче (не в памяти
|
||||
процесса), чтобы сброс базиса пережил интервал поллинга и рестарт процесса.
|
||||
Благодаря этому даже живой, но давно добавленный либо давно простаивающий
|
||||
торрент после retry SHALL получать свежее окно и на ближайшем тике сверки
|
||||
падать снова SHALL NOT.
|
||||
|
||||
Если источник задачи уже жив и ЗДОРОВ в qBittorrent, retry SHALL перецепляться
|
||||
к существующему торренту, а не добавлять источник повторно вслепую. Если же
|
||||
живой торрент в состоянии ошибки qBittorrent (`error`/`missingFiles`), retry
|
||||
перецепляться к нему SHALL NOT (перецепка к сломанному торренту тут же вернула
|
||||
бы задачу в `failed` по сверке) и SHALL повторно отдать источник, как при
|
||||
отсутствии раздачи. Повторный `Add` выполняется, только когда раздачи в
|
||||
qBittorrent нет ЛИБО она сломана.
|
||||
|
||||
Повторный `Add` при retry система SHALL выполнять **по типу источника**
|
||||
(`source_type`), как и добавление пойманной загрузки (см. `download-tracking`
|
||||
«Добавление пойманной загрузки в qBittorrent»): magnet/url — ссылкой; torrent —
|
||||
сохранёнными байтами `.torrent` файлом. Для torrent-источника retry БЕЗ живой
|
||||
раздачи система SHALL добавлять раздачу байтами и SHALL NOT активировать задачу
|
||||
в `downloading`, не добавив её (иначе задача повиснет как «нет в qBittorrent»).
|
||||
|
||||
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
|
||||
|
||||
- **GIVEN** задача в `failed`, её торрент жив и здоров в qBittorrent
|
||||
- **WHEN** пользователь нажимает retry в веб-UI
|
||||
- **THEN** задача возвращается в `downloading` без повторного `Add`
|
||||
- **AND** не падает снова на ближайшем тике сверки по таймауту
|
||||
|
||||
#### Scenario: Retry живого, но давно простаивающего торрента не падает снова
|
||||
|
||||
- **GIVEN** задача в `stuck`/`stalled`, её торрент жив в qBittorrent, но
|
||||
добавлен давно и данные не двигались дольше `stuck_after`
|
||||
- **WHEN** пользователь нажимает retry
|
||||
- **THEN** задача возвращается в `downloading` без повторного `Add`
|
||||
- **AND** на ближайшем тике сверки НЕ падает снова в `stuck` (базис сброшен
|
||||
через `retried_at`)
|
||||
|
||||
#### Scenario: Retry доступен в Telegram
|
||||
|
||||
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
|
||||
Telegram-боте
|
||||
- **THEN** задача возвращается в `downloading`
|
||||
|
||||
#### Scenario: Retry без живого источника добавляет источник заново
|
||||
|
||||
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
|
||||
- **WHEN** пользователь инициирует retry
|
||||
- **THEN** источник добавляется в qBittorrent заново — magnet/url ссылкой,
|
||||
torrent сохранёнными байтами файлом
|
||||
- **AND** задача переходит в `downloading`
|
||||
|
||||
#### Scenario: Retry сломанного живого торрента повторно отдаёт источник
|
||||
|
||||
- **GIVEN** задача в `failed`, её торрент присутствует в qBittorrent, но в
|
||||
состоянии ошибки (`error`/`missingFiles`)
|
||||
- **WHEN** пользователь инициирует retry
|
||||
- **THEN** источник отдаётся заново (перецепка к сломанному торренту не
|
||||
выполняется)
|
||||
- **AND** задача переходит в `downloading`
|
||||
|
||||
#### Scenario: Retry torrent-загрузки без живого источника
|
||||
|
||||
- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent
|
||||
нет, байты `.torrent` сохранены
|
||||
- **WHEN** пользователь инициирует retry
|
||||
- **THEN** сохранённые байты добавляются в qBittorrent файлом
|
||||
- **AND** задача переходит в `downloading` (не остаётся без раздачи)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Tasks: retry-stall-basis
|
||||
|
||||
## Схема и хранилище
|
||||
|
||||
- [x] Миграция `0010_retried_at.sql` — колонка `download.retried_at` (nullable TEXT).
|
||||
- [x] `store.Download.RetriedAt` (`db:"retried_at"`) + метод `RetriedTime()`.
|
||||
- [x] `store.SetRetriedAt(ctx, id, t)` — перезаписывающая запись базиса retry.
|
||||
- [x] Обновить ER-схему `docs/specs/database.md` (строка `retried_at`).
|
||||
|
||||
## qBittorrent-клиент
|
||||
|
||||
- [x] `qbt.Torrent.LastActivity` (`json:"last_activity"`).
|
||||
|
||||
## Логика воркера
|
||||
|
||||
- [x] `checkTimeouts` — две разные меры: `magnet_timeout` по возрасту,
|
||||
`stuck_after` по простою (`last_activity`).
|
||||
- [x] Разбить `torrentAge` на `addedBasis` (возраст, фолбэк `created_at`),
|
||||
`stallDuration` (простой от `last_activity`), `retriedFloor` (пол по
|
||||
`retried_at`).
|
||||
- [x] `Retry` — сброс базиса через `SetRetriedAt`; перецепка только к здоровому
|
||||
живому торренту, сломанный (`classErrored`) → повторный `Add` (NIT-12).
|
||||
- [x] `Store` interface воркера — метод `SetRetriedAt`.
|
||||
|
||||
## Спеки
|
||||
|
||||
- [x] Дельта `state-reconciliation` — MODIFIED «Восстановление зависшей
|
||||
загрузки» (простой от `last_activity`) и «Ручной повтор» (сброс базиса,
|
||||
NIT-12).
|
||||
- [x] `docs/specs/workflow.md` — устранить противоречие «возраст vs простой»,
|
||||
описать сброс базиса и перецепку только к здоровому торренту.
|
||||
|
||||
## Тесты
|
||||
|
||||
- [x] `TestRetryResetsTimeoutBasis` — следующий тик после retry (MAJOR-1).
|
||||
- [x] `TestStallMeasuredFromLastActivity` — простой vs возраст (MAJOR-2).
|
||||
- [x] `TestSetRetriedAtOverwrites` — store.
|
||||
- [x] Обновить фейки (`fakeStore`, `memStore`) методом `SetRetriedAt`.
|
||||
|
||||
## Проверки
|
||||
|
||||
- [x] `openspec validate --strict retry-stall-basis`.
|
||||
- [x] `task test`.
|
||||
- [x] `task lint`.
|
||||
Reference in New Issue
Block a user