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
+90 -11
View File
@@ -79,10 +79,17 @@ SHALL NOT воскрешать упавшую задачу и SHALL остави
сохраняя инвариант «не более одной активной задачи на infohash».
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck`
из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим
метаданным в `docs/specs/workflow.md`).
рабочим механизмом. Две страховочные меры при этом РАЗНЫЕ: `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
@@ -116,18 +123,40 @@ SHALL NOT воскрешать упавшую задачу и SHALL остави
- **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
сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого
`created_at`).
немедленного повторного падения по таймауту: базис отсчёта таймаутов SHALL
сбрасываться.
Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к
существующему торренту, а не добавлять источник повторно вслепую; повторный
`Add` выполняется, только когда раздачи в qBittorrent нет.
Сброс базиса система 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`
@@ -138,11 +167,20 @@ Retry SHALL переводить задачу обратно в `downloading`,
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
- **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 в
@@ -157,6 +195,15 @@ Retry SHALL переводить задачу обратно в `downloading`,
torrent сохранёнными байтами файлом
- **AND** задача переходит в `downloading`
#### Scenario: Retry сломанного живого торрента повторно отдаёт источник
- **GIVEN** задача в `failed`, её торрент присутствует в qBittorrent, но в
состоянии ошибки (`error`/`missingFiles`)
- **WHEN** пользователь инициирует retry
- **THEN** источник отдаётся заново (перецепка к сломанному торренту не
выполняется)
- **AND** задача переходит в `downloading`
#### Scenario: Retry torrent-загрузки без живого источника
- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent
@@ -364,3 +411,35 @@ SHALL отклоняться сразу с пояснением, что исто
`nlink > 1`
- **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым
### 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` их состояние не меняет