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:
av
2026-07-08 17:21:22 +03:00
co-authored by Claude Opus 4.8
parent 9bab7dc402
commit 4cc4de4269
19 changed files with 178 additions and 22 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-08
@@ -0,0 +1,79 @@
## Context
Приём дедуплицирует входящий источник по активной задаче двумя путями:
1. **Быстрый чек**`Ingest` вызывает `FindActiveByInfohash`, при попадании
уходит в `attached()` (дозапись недостающих хешей через `AddInfohashes`).
Это основной путь.
2. **Гонка** — быстрый чек пуст, но пока выводили имя/готовили запись,
активная задача появилась; `CreateDownloadIfNoActive` внутри своей
транзакции находит её и возвращает как дедуп (дозапись хешей внутри той же
tx).
F1 живёт в пути 2 (неохраняемая дозапись). F6 задевает ОБА пути: в реальном
сценарии первым отрабатывает быстрый чек (путь 1), поэтому апгрейд обязан
работать и там.
## F1 — пер-хеш гард дозаписи в дедуп-ветке
**Решение.** В дедуп-ветке `CreateDownloadIfNoActive` заменяем безусловный
цикл `INSERT OR IGNORE` на тот же гард, что в `AddInfohashes`: для каждого
хеша `h` проверяем `findActiveByInfohash(ctx, tx, {h}, existing.ID)`; если
другая активная задача владеет `h` — пропускаем (не дописываем), иначе
`INSERT OR IGNORE`. Транзакция уже открыта, `excludeID = existing.ID`
исключает саму дедуп-цель (её собственные хеши не конфликтуют с ней самой).
**Почему пропуск, а не ошибка.** Дедуп по контракту не падает — возвращает
существующую задачу. Конфликтный хеш принадлежит другой активной задаче;
молча его не трогаем — это и есть сохранение инварианта. В отличие от
`AddInfohashes`, который сигналит `ErrInfohashTaken` вызывающему (там это
осмысленно), у дедуп-ветки наблюдаемого канала ошибки нет и он не нужен:
поведение — «присоединиться к найденной задаче, чужое не красть».
## F6 — апгрейд catched-magnet до torrent
**Решение.** Новый guarded-метод хранилища:
```
UpgradeCatchedMagnetToTorrent(ctx, downloadID string, torrentBlob []byte) (bool, error)
```
в одной write-транзакции:
1. Пустой `torrentBlob``(false, nil)` (защитный no-op).
2. Гардированный UPDATE:
`UPDATE download SET source_type='torrent', updated_at=?
WHERE id=? AND source_type='magnet' AND state='catched'`.
`RowsAffected==0` → задача не подходит (уже `downloading`/отменена/не
magnet) → коммит без эффекта, `(false, nil)`.
3. `RowsAffected==1` → `INSERT OR REPLACE INTO download_torrent(download_id,
data)` (у magnet блоба нет; `OR REPLACE` — страховка идемпотентности),
коммит, `(true, nil)`.
**Почему гард `state='catched'`.** Апгрейд имеет смысл только пока worker ещё
не отдал источник в qBittorrent. В `catched` worker на шаге добавления
выбирает способ по `source_type` (`internal/worker/worker.go:sourceAddParts`):
после смены на `torrent` он добавит файлом — метаданные приедут сразу. Если
задача уже `downloading`, magnet давно в qBittorrent (застрял в metaDL), и
смена `source_type` его не переотдаст; это отдельная забота retry/desync, не
приёма. Тот же паттерн ре-валидации, что у `PromoteCatched`: если задачу
успели отменить, UPDATE не заденет строк и апгрейд просто не применится.
**Где вызываем.** Оба дедуп-пути `Ingest` сводим к `attached()` (в ветке
гонки `existing` от `CreateDownloadIfNoActive` уже с подгруженными хешами, так
что переиспользование безопасно). В `attached()` после дозаписи хешей: если
входящий источник — `torrent` и есть байты, зовём
`UpgradeCatchedMagnetToTorrent`. Вызов best-effort: ошибка/неуспех логируются
`Warn`/`Info`, приём не валится (как и дозапись хешей). Результат приёма
по-прежнему несёт `State` существующей задачи (остаётся `catched`) — меняется
лишь способ будущего добавления.
**Идемпотентность и повтор.** Повторный `.torrent` того же хеша: первый
апгрейд перевёл задачу в `torrent`, гард `source_type='magnet'` на втором даст
`RowsAffected==0` → no-op. Байты уже сохранены при создании torrent-ветки —
`OR REPLACE` перезапишет теми же данными без вреда.
## Границы
Схему не трогаем: `download_torrent` и колонка `source_type` уже есть. ER-схема
`docs/specs/database.md` без изменений. Апгрейд — операция над данными.
@@ -0,0 +1,56 @@
## Why
Ревью приёма (Fable, 2026-07-08) вскрыло два дефекта в дедуп-ветках приёма,
оба про инвариант «≤1 активная загрузка на infohash» и про сохранность
источника:
- **F1 — неохраняемая дозапись хешей.** Дедуп-ветка
`CreateDownloadIfNoActive` (`internal/store/download.go`) безусловно
дописывает ВСЕ хеши входящего источника в найденную активную задачу
(`INSERT OR IGNORE`) без пер-хеш гарда владения — в отличие от
`AddInfohashes`, где гард есть. Это единственная неохраняемая запись хешей,
и она в авторитетном методе инварианта. Сценарий: активная A владеет `v1`,
активная B владеет `v2` того же гибридного торрента; приём гибрида
`{v1,v2}`, дедупнувшись на B, допишет `v1` в B → две активные владеют `v1`.
Инвариант нарушен.
- **F6 — потерянный upgrade-путь `.torrent` поверх magnet.** Пользователь
сначала ловит magnet с закрытого трекера (задача `catched`,
`source_type=magnet`), понимает, что без DHT метаданные не докачаются, и
грузит правильный `.torrent`. Приём дедупит по infohash на magnet-задачу; по
текущей спеке байты при дедупе НЕ сохраняются, `source_type` остаётся
`magnet`. Worker добавляет раздачу по magnet-URL → вечный metaDL → failed.
Ровно тот артефакт, который бы починил загрузку, выбрасывается с «уже в
работе».
## What Changes
- **F1:** дедуп-ветка `CreateDownloadIfNoActive` применяет тот же пер-хеш
гард, что и `AddInfohashes`: хеш, которым владеет ДРУГАЯ активная задача,
не дописывается. Транзакция уже открыта — правка внутри неё.
- **F6:** при дедупе, где входящее — байты `.torrent`, а активная задача
поймана как `magnet` и ещё не добавлена в qBittorrent (состояние
`catched`), система сохраняет байты и меняет `source_type` на `torrent` в
одной транзакции. Тогда worker добавит раздачу файлом и метаданные не
придётся докачивать по DHT. Это ПРОТИВОРЕЧИТ действующему правилу спеки
ingest «при дедупликации байты сохраняться SHALL NOT» — правило смягчается
этим целевым исключением (MODIFIED-дельта).
Схема БД не меняется: таблица `download_torrent` уже есть, `source_type`
существующая колонка; апгрейд — изменение данных, не структуры.
## Capabilities
### Modified Capabilities
- `ingest`: уточняется поведение дедупа (пер-хеш гард дозаписи; сохранение
`.torrent`-байт и смена источника при апгрейде `catched`-magnet).
## Impact
- Код: `internal/store/download.go` (гард F1 + новый guarded-метод апгрейда),
`internal/ingest/ingest.go` (вызов апгрейда на обоих дедуп-путях).
- Тесты: `internal/store`, `internal/ingest`.
- Совместимость: изменение только ужесточает инвариант (F1) и добавляет
целевой апгрейд (F6); существующие потоки без `.torrent`-поверх-magnet
ведут себя как прежде.
@@ -0,0 +1,154 @@
## MODIFIED Requirements
### Requirement: Атомарность возврата загрузки в активное состояние
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
возвращающем загрузку из терминального состояния в активное (ручной retry,
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
сохраняя инвариант «не более одной активной загрузки на infohash».
Отказ SHALL происходить до побочных эффектов во внешних системах
(повторного добавления торрента в qBittorrent).
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
гибридного торрента) на ВСЕХ путях дозаписи, включая дедуп-дозапись при
приёме: хеш, которым владеет другая активная загрузка, дописан быть SHALL
NOT — ни отдельным методом дозаписи, ни дедуп-веткой атомарного заведения,
которая доносит недостающие хеши найденной активной задаче. Прямой перевод
терминальной загрузки в активное состояние в обход этой проверки SHALL
отклоняться хранилищем (механический бэкстоп вместо удалённого
unique-индекса).
#### Scenario: Retry при занятом хеше
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
#2 с тем же `h`
- **WHEN** пользователь вызывает retry для #1
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
- **AND** активной по `h` остаётся #2
#### Scenario: Дедуп-дозапись не крадёт чужой хеш
- **GIVEN** активная загрузка A владеет хешем `v1`, активная загрузка B
владеет хешем `v2` того же гибридного торрента
- **WHEN** принимается источник с обоими хешами `{v1, v2}` и дедупится на B
- **THEN** B получает только незанятые хеши, а `v1` (в собственности A) B не
дописывается
- **AND** инвариант «не более одной активной загрузки на infohash»
сохраняется (по `v1` активна только A)
### Requirement: Приём источника из .torrent-файла
Приём SHALL принимать источник в виде **байтов `.torrent`-файла** (наряду с
magnet-ссылкой) — тем же быстрым use-case, общим для транспортов. Получив
непустые байты торрента, система SHALL разобрать их локально (без сети),
извлечь инфохэш(и) и завести загрузку с `source_type = torrent`, после чего
сразу вернуть ответ транспорту (синхронный путь к qBittorrent не обращается —
добавление делает воркер, см. `download-tracking`).
Инфохэши система SHALL извлекать такими, какими их сообщает qBittorrent, чтобы
сопоставление раздач и дедупликация работали: v1-хеш (для v1/гибридного файла)
SHALL вычисляться как SHA1 **исходных** байтов info-словаря (без переэнкода);
v2-хеш (для v2/гибридного файла, BEP52) SHALL извлекаться как 64-hex `infohash_v2`.
Для чистого v2-only файла система SHALL записывать v2-хеш (v1 у него нет).
Извлечение всех известных хешей и дозапись недостающих подчиняются требованию
«Множество инфохэшей загрузки».
Дедупликацию по активной задаче, атомарное заведение (`download` в состоянии
`catched` + записи `download_infohash`) и инвариант «не более одной активной
загрузки на infohash» torrent-приём SHALL проходить тем же атомарным путём, что
и magnet (см. «Приём источника и заведение загрузки», «Дедупликация приёма по
любому из хешей», «Атомарность возврата загрузки в активное состояние»).
Байты `.torrent` система SHALL сохранять персистентно, привязанными к загрузке,
чтобы воркер мог добавить источник в qBittorrent именно файлом (не по magnet):
раздачи закрытых трекеров и торренты без DHT по magnet-хешу метаданные не
получат. Сохранение байтов SHALL выполняться в той же write-транзакции, что и
заведение загрузки; при дедупликации (новая загрузка не создана) байты в общем
случае сохраняться SHALL NOT.
**Исключение — апгрейд пойманной magnet-задачи до torrent.** Если входящий
источник — байты `.torrent`, а дедуп попал на активную загрузку с
`source_type = magnet`, ещё НЕ отданную в qBittorrent (состояние `catched`),
система SHALL в одной write-транзакции сохранить байты `.torrent`,
привязав их к этой загрузке, и сменить её `source_type` на `torrent`. Тем
самым воркер добавит раздачу файлом, а не magnet-хешем (иначе на закрытом
трекере без DHT метаданные не докачаются, а magnet застрянет в metaDL →
failed). Апгрейд SHALL применяться ТОЛЬКО пока загрузка в `catched` (воркер
источник ещё не добавил); для уже добавленной (`downloading` и далее)
загрузки смена `source_type` при дедупе выполняться SHALL NOT — её судьба
решается путями retry/сверки, а не приёмом. Апгрейд SHALL быть best-effort:
его неуспех приём не прерывает.
Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя
раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий)
и **дополнять** им контекст транспорта — тем же правилом слияния, что и синтез
из полей magnet (пользовательский текст первым; при пустом тексте — только
синтез). Обогащённый контекст система SHALL сохранять в `download.Context`.
Синтез SHALL выполняться без сетевых запросов.
`source_ref` у torrent-загрузки SHALL быть человекочитаемым референсом (имя
раздачи или файла), а НЕ адресом добавления: добавление в qBittorrent идёт
байтами, и трактовать `source_ref` как magnet/URL для добавления система SHALL
NOT.
#### Scenario: Быстрый приём .torrent-файла
- **GIVEN** валидные байты `.torrent`-файла и (опц.) текст контекста
- **WHEN** вызывается приём
- **THEN** из файла извлекаются инфохэши и создаётся `download` в состоянии
`catched` (`source_type = torrent`) с записями `download_infohash`
- **AND** байты файла сохраняются привязанными к загрузке
- **AND** ответ транспорту отдан без обращения к qBittorrent
#### Scenario: Инфохэш из исходных байтов info
- **WHEN** система разбирает v1/гибридный `.torrent`-файл
- **THEN** инфохэш v1 вычисляется как SHA1 исходных байтов info-словаря
- **AND** совпадает с хешем, по которому qBittorrent позже сопоставит раздачу
#### Scenario: v2-only файл записывается под v2-хешем
- **WHEN** система разбирает `.torrent` только с метаданными v2 (без v1)
- **THEN** у загрузки записывается v2-хеш (64-hex), совпадающий с `infohash_v2`
qBittorrent
- **AND** сопоставление раздачи работает по нему
#### Scenario: Дубль .torrent по активной torrent-задаче
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash и
`source_type = torrent`
- **WHEN** принимается `.torrent` с тем же инфохэшем
- **THEN** новая загрузка не создаётся, возвращается существующая
- **AND** байты торрента повторно не сохраняются (дубль)
#### Scenario: Апгрейд catched-magnet до torrent
- **GIVEN** активная загрузка в `catched` с `source_type = magnet` и хешем `h`
(magnet-задача ещё не отдана в qBittorrent)
- **WHEN** принимается `.torrent` с тем же инфохэшем `h`
- **THEN** новая загрузка не создаётся, возвращается существующая
- **AND** байты `.torrent` сохраняются привязанными к ней, а её `source_type`
становится `torrent` — в одной транзакции
- **AND** воркер добавит раздачу файлом (не по magnet)
#### Scenario: Magnet-задача уже добавлена — апгрейда нет
- **GIVEN** активная загрузка с `source_type = magnet` уже в `downloading`
(отдана в qBittorrent)
- **WHEN** принимается `.torrent` с тем же инфохэшем
- **THEN** возвращается существующая загрузка, её `source_type` остаётся
`magnet`, байты `.torrent` не сохраняются
#### Scenario: Контекст из полей файла
- **WHEN** принят `.torrent` с именем раздачи, деревом файлов и трекерами
- **THEN** в `download.Context` добавляется синтез (имя, размер, сигнал по
файлам, домен трекера), дополняющий текст транспорта
- **AND** синтез выполнен без сетевых запросов
#### Scenario: Слишком большой .torrent отклоняется
- **WHEN** принимаемый `.torrent`-файл превышает ограничение размера
- **THEN** приём отклоняется с ошибкой, загрузка не создаётся
@@ -0,0 +1,29 @@
## 1. F1 — пер-хеш гард дедуп-дозаписи
- [x] 1.1 В `CreateDownloadIfNoActive` (`internal/store/download.go`) заменить
безусловный цикл `INSERT OR IGNORE` дедуп-ветки на пер-хеш гард как в
`AddInfohashes`: пропускать хеш, которым владеет другая активная задача
(`findActiveByInfohash(..., existing.ID)`), внутри уже открытой tx.
- [x] 1.2 Тест в `internal/store`: гибридный дедуп на B не крадёт хеш,
принадлежащий активной A (инвариант сохранён).
## 2. F6 — апгрейд catched-magnet до torrent
- [x] 2.1 Добавить guarded-метод хранилища
`UpgradeCatchedMagnetToTorrent(ctx, downloadID, torrentBlob) (bool, error)`:
в одной tx гардированным UPDATE `source_type='torrent'` при
`source_type='magnet' AND state='catched'`, затем сохранить байты в
`download_torrent`; вернуть, был ли апгрейд.
- [x] 2.2 В `internal/ingest/ingest.go` свести оба дедуп-пути к `attached()` и
вызвать апгрейд, когда входящий источник — torrent с байтами
(best-effort: неуспех логируется, приём не валится).
- [x] 2.3 Обновить интерфейс `ingest.Store` и `fakeStore` в тестах.
- [x] 2.4 Тесты в `internal/store`: апгрейд из `catched`+magnet сохраняет байты
и меняет `source_type`; из `downloading`/не-magnet — no-op. Тест в
`internal/ingest`: дедуп `.torrent` на catched-magnet вызывает апгрейд.
## 3. Проверки
- [x] 3.1 `openspec validate --strict ingest-dedup-integrity`
- [x] 3.2 `task test`
- [x] 3.3 `task lint`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-08
@@ -0,0 +1,72 @@
# Design
## Контекст
`linking` — короткое рабочее состояние между «решили раскладывать» и
«разложили». Оно нетерминально и активно, но в отличие от `recognizing` его
никто не листит на рестарте, а переход в него — обычный `SetDownloadState`, чей
сбой раньше проглатывался. Обе дыры (MINOR-7, MAJOR-4) — про то, что `linking`
не был устойчивым владельцем шага.
## Решение 1: `transition` возвращает ошибку — но только там, где она нужна
Параллельный поток правит соседние функции воркера (`Retry`/`checkTimeouts`/
`torrentAge`), поэтому смена сигнатуры `transition` на всех ~20 вызовах
(с добавлением `_ =` в fire-and-forget местах) создала бы лишние конфликты
слияния и шум. Вместо этого:
- `transition(...)` остаётся `void` — обёртка, гасящая ошибку. Все существующие
вызовы (reconcile, таймауты, команды ревью, финальные переходы `linkPlan`,
sweep) не трогаются: за ними НЕТ побочного эффекта, зависящего от факта записи
claim, — переход и есть конец шага.
- `transitionErr(...)` — новая функция, тело прежнего `transition` + `return
error`. Пинги/скан живут в ней (обёртка делегирует).
**Развилка:** менять сигнатуру `transition` глобально (честнее, но шумно и
конфликтно) против точечного `transitionErr` (`mustTransition` из ревью). Выбран
точечный вариант: минимальный след, локальные правки по функциям, ошибка
возвращается ровно там, где за claim следует побочный эффект.
Использование: `Apply` и авто-раскладка в `finishRecognition` зовут
`transitionErr(StateLinking)` и прерываются при ошибке ДО `linkPlan`. При провале
claim `Apply` остаётся в `review`/`deferred`, а авто-путь — в `recognizing`
(его повторит `recognizePending`); в обоих случаях владелец шага сохраняется.
## Решение 2: провал `CreateFileLinks` уводит в `review`, а не оставляет в `linking`
Хардлинки к этому моменту уже на диске — это учётный, а не безопасностный сбой
(файлы разложены). Оставлять задачу в `linking` нельзя (осиротеет до sweep, а до
того файлы висят без `file_link`). Уводим в `review` с кодом `persist`: повторный
`Apply` идемпотентен — `layout.Apply` вернёт `StatusExists` на уже созданных
ссылках, а `CreateFileLinks` допишет учёт.
**Почему `review`, а не `failed`:** план валиден, сбой транзиентный, самолечение
через повтор естественно ложится в петлю ревью (как коллизия). `failed` уводил
бы в восстановление сверкой, которое к этому кейсу не относится.
Соседний сбой `SupersedeForeignLinks` уже трактуется как учётный (WARN, доводим
до `done`) — тот кейс не меняем: там файлы разложены И учтены, чужой рассинхрон
починит следующий тик сверки.
## Решение 3: sweep осиротевшего `linking` на тике и старте
Новый шаг `sweepLinking` в `pollOnce` (выполняется и первым вызовом до цикла —
это «старт»). Берёт `w.mu`, листит `linking`, каждую переводит `linking → review`
(ребро уже в графе) с кодом `interrupted`.
**Ключ корректности:** активная раскладка (`linkPlan`) держит `w.mu` на весь свой
срок и завершает переход ИЗ `linking` до отпускания замка. Значит любая
`linking`-задача, которую `sweepLinking` видит, взяв `w.mu`, гарантированно НЕ
в полёте — она осталась после краша между claim и финальным переходом. Ложных
срабатываний на живой раскладке нет.
Так `linking` получает владельца на рестарте/тике — по образцу `recognizing`
(`recognizePending`); инвариант «у каждого нетерминального состояния есть
владелец» восстановлен.
## Что НЕ делаем
- Не трогаем граф переходов: ребро `linking → review` уже объявлено в
`allowedTransitions`.
- Не меняем схему БД.
- Не меняем сигнатуру `transition` глобально (см. Решение 1).
@@ -0,0 +1,61 @@
## Why
Раскладка хардлинками устроена как «claim-then-side-effect»: сначала задача
переводится в `linking` (claim владения шагом), затем создаются хардлинки и
пишется их учёт (`file_link`). Ревью жизненного цикла (Fable, 2026-07-08)
нашло две связанные дыры устойчивости этого пути.
- **MINOR-7:** `worker.transition` при ошибке записи состояния логировал её, но
НЕ возвращал вызывающему. На путях `Apply` и авто-раскладки в
`finishRecognition` выполнение продолжалось к побочным эффектам: хардлинки
создавались, пока claim перехода в `linking` не закоммичен. Финальный переход
`linking → done` оценивался графом как `review → done` (нелегальное ребро) и
отклонялся — задача застревала в `review` со stale-планом, скан/уведомление не
срабатывали.
- **MAJOR-4:** задача может осиротеть в `linking`:
(A) без краха — хардлинки созданы, но `CreateFileLinks` упал транзиентно
(SQLite busy) → голый `return` оставлял задачу в `linking`, а файлы на диске —
без строк `file_link`;
(B) краш процесса между переходом в `linking` и финальным переходом → на
рестарте `linking` не листит НИКТО (поллинг листит `downloading`, распознавание
`completed`/`recognizing`, сверка — `done`/`target_missing`/`orphaned`,
восстановление — `failed`/`stuck`). Задача сидит в `linking` вечно; выход —
только ручной Cancel/Defer (недискаверабельно). Нарушен инвариант «у каждого
нетерминального состояния есть владелец» (`recognizing` уже лечится
рестартом через `recognizePending`, `linking` — нет).
## What Changes
- `transition` разделяется на fire-and-forget обёртку (прежнее имя, прежнее
поведение для reconcile/таймаутов/финальных переходов) и `transitionErr`,
которая ВОЗВРАЩАЕТ ошибку записи. На claim-then-side-effect путях (`Apply`,
авто-раскладка в `finishRecognition`) провал claim перехода в `linking` теперь
прерывает выполнение ДО хардлинков.
- В `linkPlan` провал `CreateFileLinks` больше не оставляет задачу в `linking`:
задача уходит в `review` с кодом `persist` и причиной; повторный `Apply`
идемпотентен (хардлинки уже на диске → `StatusExists`, учёт дописывается).
- Новый шаг поллинга `sweepLinking`: на каждом тике и на старте задачи в
`linking` возвращаются в `review` с кодом `interrupted` и причиной
«прерванная раскладка, повтори применение». Любая `linking`, видимая под
`w.mu`, устарела по построению (активная раскладка держит `w.mu` весь свой
срок), значит осталась после краха.
## Capabilities
### Modified Capabilities
- `file-layout`: раскладка становится устойчивой к сбою записи claim/учёта —
хардлинки не создаются при незакоммиченном claim, а сбой записи учёта не
стрэндит задачу в `linking`.
- `state-reconciliation`: у нетерминального `linking` появляется владелец на
рестарте/тике — sweep осиротевших `linking` в `review`.
## Impact
- **Код:** `internal/worker/worker.go` (`transition`/`transitionErr`, `pollOnce`,
`sweepLinking`), `internal/worker/review.go` (`Apply`, `finishRecognition`,
`linkPlan`). Граф переходов (`internal/store/download.go`) правки не требует —
ребро `linking → review` уже объявлено.
- **Тесты:** провал claim прерывает до хардлинков; провал `CreateFileLinks`
уводит в `review` (файлы на диске); sweep осиротевшего `linking``review`.
- **БД/схема:** без изменений.
@@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Claim раскладки коммитится до хардлинков и устойчив к сбою учёта
Раскладка — «claim-then-side-effect»: система SHALL сперва зафиксировать переход
задачи в `linking` (claim шага раскладки), и только затем создавать хардлинки.
Если запись claim перехода в `linking` провалилась, система НЕ SHALL создавать
хардлинки и SHALL прервать раскладку, оставив задачу в исходном состоянии
(`review`/`deferred` при ручном применении; `recognizing` при авто-раскладке) —
чтобы у шага сохранился владелец, а хардлинки не легли при незакоммиченном claim
(иначе финальный переход `linking → done` из фактического состояния был бы
отклонён графом, и задача застряла бы со stale-планом).
Если хардлинки уже созданы, но запись их учёта (`file_link`) провалилась
(транзиентная ошибка хранилища), задача НЕ SHALL оставаться в `linking`: система
SHALL перевести её в `review` с причиной. Повторное применение SHALL быть
идемпотентным — уже созданные хардлинки распознаются как существующие
(`StatusExists`), а их учёт дописывается.
#### Scenario: Провал claim не создаёт хардлинков
- **GIVEN** задача в `review` с готовым источником и валидным планом
- **WHEN** запись перехода в `linking` проваливается
- **THEN** хардлинки не создаются, учёт `file_link` не пишется
- **AND** задача остаётся в `review`, а команда отказывает с ошибкой
#### Scenario: Провал учёта уводит в review, не оставляя в linking
- **GIVEN** хардлинки по плану уже созданы на файловой системе
- **WHEN** запись строк `file_link` проваливается транзиентной ошибкой
- **THEN** задача переходит в `review` с причиной (код `persist`), а не остаётся
в `linking`
- **AND** созданные хардлинки остаются на диске
- **AND** повторное «Применить» идемпотентно дописывает учёт и доводит до `done`
@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Восстановление задачи, застрявшей в linking
Система SHALL на каждом тике поллинга и при старте выявлять задачи в состоянии
`linking` и возвращать их в `review` с причиной «прерванная раскладка» (код
`interrupted`), откуда человек повторит применение (повтор идемпотентен).
`linking` — нетерминальное активное состояние, и у него, как у каждого
нетерминального состояния, ДОЛЖЕН быть владелец, продвигающий задачу; иначе
краш процесса между переходом в `linking` и финальным переходом оставил бы
задачу без владельца — её не листит ни один штатный шаг (ни поллинг активных,
ни распознавание, ни матрица сверки, ни восстановление `failed`/`stuck`).
Выявление SHALL выполняться под той же блокировкой переходов, что и раскладка:
активная раскладка удерживает блокировку весь свой срок и завершает переход из
`linking` до её отпускания, поэтому любая `linking`-задача, наблюдаемая под
блокировкой, по построению устарела (осталась после краха) — восстановление НЕ
SHALL задевать раскладку в полёте.
#### Scenario: Осиротевший linking возвращается в review
- **GIVEN** задача осталась в `linking` после краха между claim и финальным
переходом
- **WHEN** выполняется тик поллинга (или старт сервиса)
- **THEN** задача переходит в `review` с причиной «прерванная раскладка»
(код `interrupted`)
- **AND** её можно повторно применить из ревью
#### Scenario: Прочие состояния sweep не задевает
- **GIVEN** задачи в состояниях `done` и `review`
- **WHEN** выполняется тик поллинга
- **THEN** восстановление `linking` их состояние не меняет
@@ -0,0 +1,34 @@
## 1. Возврат ошибки перехода (MINOR-7)
- [x] 1.1 Разделить `transition` на `void`-обёртку и `transitionErr`
(возвращает ошибку записи); пинги/скан — в `transitionErr`
- [x] 1.2 `Apply`: заменить claim `transition(StateLinking)` на
`transitionErr` с прерыванием до `linkPlan` при ошибке
- [x] 1.3 `finishRecognition` (авто-раскладка): то же — при провале claim
остаёмся в `recognizing`, `linkPlan` не зовём
## 2. Провал учёта не оставляет в linking (MAJOR-4 A)
- [x] 2.1 В `linkPlan` при провале `CreateFileLinks` перевести задачу в
`review` (код `persist`) вместо голого `return`
## 3. Sweep осиротевшего linking (MAJOR-4 B)
- [x] 3.1 Добавить `sweepLinking`: под `w.mu` листить `linking` и переводить
`linking → review` (код `interrupted`, причина «прерванная раскладка,
повтори применение»)
- [x] 3.2 Вызвать `sweepLinking` в `pollOnce` (тик + старт)
## 4. Тесты
- [x] 4.1 Провал claim перехода в `linking` прерывает `Apply` до хардлинков
(нет файлов на диске, нет `file_link`, состояние `review`)
- [x] 4.2 Провал `CreateFileLinks` уводит в `review` (файлы на диске есть,
код `persist`)
- [x] 4.3 `sweepLinking` переводит осиротевший `linking` в `review`
(код `interrupted`); прочие состояния не задевает
## 5. Проверки
- [x] 5.1 `task test` и `task lint` проходят
- [x] 5.2 `openspec validate linking-transition-robustness --strict` проходит
@@ -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).
@@ -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`.