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:
@@ -111,3 +111,36 @@ NOT влиять на инвариант неприкосновенности и
|
|||||||
- **THEN** суммарный размер разложенных файлов доступен как размер раздачи для
|
- **THEN** суммарный размер разложенных файлов доступен как размер раздачи для
|
||||||
показа в карточке
|
показа в карточке
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
|
||||||
|
|||||||
@@ -207,10 +207,13 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
|
|||||||
(повторного добавления торрента в qBittorrent).
|
(повторного добавления торрента в qBittorrent).
|
||||||
|
|
||||||
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
|
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
|
||||||
гибридного торрента): хеш, которым владеет другая активная загрузка,
|
гибридного торрента) на ВСЕХ путях дозаписи, включая дедуп-дозапись при
|
||||||
дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное
|
приёме: хеш, которым владеет другая активная загрузка, дописан быть SHALL
|
||||||
состояние в обход этой проверки SHALL отклоняться хранилищем (механический
|
NOT — ни отдельным методом дозаписи, ни дедуп-веткой атомарного заведения,
|
||||||
бэкстоп вместо удалённого unique-индекса).
|
которая доносит недостающие хеши найденной активной задаче. Прямой перевод
|
||||||
|
терминальной загрузки в активное состояние в обход этой проверки SHALL
|
||||||
|
отклоняться хранилищем (механический бэкстоп вместо удалённого
|
||||||
|
unique-индекса).
|
||||||
|
|
||||||
#### Scenario: Retry при занятом хеше
|
#### Scenario: Retry при занятом хеше
|
||||||
|
|
||||||
@@ -220,6 +223,16 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
|
|||||||
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
|
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
|
||||||
- **AND** активной по `h` остаётся #2
|
- **AND** активной по `h` остаётся #2
|
||||||
|
|
||||||
|
#### Scenario: Дедуп-дозапись не крадёт чужой хеш
|
||||||
|
|
||||||
|
- **GIVEN** активная загрузка A владеет хешем `v1`, активная загрузка B
|
||||||
|
владеет хешем `v2` того же гибридного торрента
|
||||||
|
- **WHEN** принимается источник с обоими хешами `{v1, v2}` и дедупится на B
|
||||||
|
- **THEN** B получает только незанятые хеши, а `v1` (в собственности A) B не
|
||||||
|
дописывается
|
||||||
|
- **AND** инвариант «не более одной активной загрузки на infohash»
|
||||||
|
сохраняется (по `v1` активна только A)
|
||||||
|
|
||||||
### Requirement: Синтез контекста распознавания из полей magnet
|
### Requirement: Синтез контекста распознавания из полей magnet
|
||||||
|
|
||||||
При приёме система SHALL извлекать из полей magnet-ссылки дополнительный
|
При приёме система SHALL извлекать из полей magnet-ссылки дополнительный
|
||||||
@@ -314,7 +327,6 @@ recognition (LLM-промпт) и веб-UI (страница загрузки).
|
|||||||
- **THEN** `download.Context` остаётся пустым
|
- **THEN** `download.Context` остаётся пустым
|
||||||
- **AND** приём проходит штатно (пустой контекст допустим)
|
- **AND** приём проходит штатно (пустой контекст допустим)
|
||||||
|
|
||||||
|
|
||||||
### Requirement: Приём источника из .torrent-файла
|
### Requirement: Приём источника из .torrent-файла
|
||||||
|
|
||||||
Приём SHALL принимать источник в виде **байтов `.torrent`-файла** (наряду с
|
Приём SHALL принимать источник в виде **байтов `.torrent`-файла** (наряду с
|
||||||
@@ -342,9 +354,21 @@ v2-хеш (для v2/гибридного файла, BEP52) SHALL извлек
|
|||||||
чтобы воркер мог добавить источник в qBittorrent именно файлом (не по magnet):
|
чтобы воркер мог добавить источник в qBittorrent именно файлом (не по magnet):
|
||||||
раздачи закрытых трекеров и торренты без DHT по magnet-хешу метаданные не
|
раздачи закрытых трекеров и торренты без DHT по magnet-хешу метаданные не
|
||||||
получат. Сохранение байтов SHALL выполняться в той же write-транзакции, что и
|
получат. Сохранение байтов SHALL выполняться в той же write-транзакции, что и
|
||||||
заведение загрузки; при дедупликации (новая загрузка не создана) байты
|
заведение загрузки; при дедупликации (новая загрузка не создана) байты в общем
|
||||||
сохраняться SHALL NOT. Размер принимаемого `.torrent` система SHALL ограничивать
|
случае сохраняться 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 синтезировать контекст распознавания (имя
|
Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя
|
||||||
раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий)
|
раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий)
|
||||||
@@ -380,12 +404,31 @@ NOT.
|
|||||||
qBittorrent
|
qBittorrent
|
||||||
- **AND** сопоставление раздачи работает по нему
|
- **AND** сопоставление раздачи работает по нему
|
||||||
|
|
||||||
#### Scenario: Дубль .torrent по активной задаче
|
#### Scenario: Дубль .torrent по активной torrent-задаче
|
||||||
|
|
||||||
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
|
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash и
|
||||||
|
`source_type = torrent`
|
||||||
- **WHEN** принимается `.torrent` с тем же инфохэшем
|
- **WHEN** принимается `.torrent` с тем же инфохэшем
|
||||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||||
- **AND** байты торрента не сохраняются (дубль)
|
- **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: Контекст из полей файла
|
#### Scenario: Контекст из полей файла
|
||||||
|
|
||||||
@@ -398,3 +441,4 @@ NOT.
|
|||||||
|
|
||||||
- **WHEN** принимаемый `.torrent`-файл превышает ограничение размера
|
- **WHEN** принимаемый `.torrent`-файл превышает ограничение размера
|
||||||
- **THEN** приём отклоняется с ошибкой, загрузка не создаётся
|
- **THEN** приём отклоняется с ошибкой, загрузка не создаётся
|
||||||
|
|
||||||
|
|||||||
@@ -79,10 +79,17 @@ SHALL NOT воскрешать упавшую задачу и SHALL остави
|
|||||||
сохраняя инвариант «не более одной активной задачи на infohash».
|
сохраняя инвариант «не более одной активной задачи на infohash».
|
||||||
|
|
||||||
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
|
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
|
||||||
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
|
рабочим механизмом. Две страховочные меры при этом РАЗНЫЕ: `magnet_timeout`
|
||||||
прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck`
|
SHALL мериться по **возрасту** торрента (время от добавления в qBittorrent,
|
||||||
из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим
|
`added_on`, с фолбэком на `created_at` задачи), а `stuck_after` — по
|
||||||
метаданным в `docs/specs/workflow.md`).
|
**длительности простоя** (время от `last_activity` qBittorrent — момента
|
||||||
|
последнего движения данных), а НЕ по возрасту. Пока торрент в
|
||||||
|
`metaDL`/`forcedMetaDL` или иным образом прогрессирует в пределах
|
||||||
|
страховочного таймаута, задача в `failed`/`stuck` из-за него оказаться
|
||||||
|
SHALL NOT; в частности, торрент со свежим `last_activity` в `stuck` система
|
||||||
|
пометить SHALL NOT, даже если его общий возраст превышает `stuck_after` (см.
|
||||||
|
требование о терпеливости к долгим метаданным и меры таймаутов в
|
||||||
|
`docs/specs/workflow.md`).
|
||||||
|
|
||||||
#### Scenario: Метаданные пришли после magnet_timeout
|
#### Scenario: Метаданные пришли после magnet_timeout
|
||||||
|
|
||||||
@@ -116,18 +123,40 @@ SHALL NOT воскрешать упавшую задачу и SHALL остави
|
|||||||
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
|
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
|
||||||
- **AND** активной по этому infohash остаётся #2
|
- **AND** активной по этому infohash остаётся #2
|
||||||
|
|
||||||
|
#### Scenario: Долго качавшийся торрент на миг зашёл в stalledDL
|
||||||
|
|
||||||
|
- **GIVEN** торрент качался часами и двигал данные только что (свежий
|
||||||
|
`last_activity`), но на текущем тике qBittorrent показывает его `stalledDL`
|
||||||
|
- **WHEN** `worker` проверяет таймаут зависания
|
||||||
|
- **THEN** задача остаётся в `downloading` (простой меньше `stuck_after`),
|
||||||
|
несмотря на большой возраст торрента
|
||||||
|
- **AND** ложного `stuck` со «stalled for <возраст>» и уведомления о падении
|
||||||
|
не возникает
|
||||||
|
|
||||||
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
|
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
|
||||||
|
|
||||||
Система SHALL предоставлять пользователю команду повторной попытки (retry)
|
Система SHALL предоставлять пользователю команду повторной попытки (retry)
|
||||||
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
|
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
|
||||||
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
|
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
|
||||||
немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL
|
немедленного повторного падения по таймауту: базис отсчёта таймаутов SHALL
|
||||||
сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого
|
сбрасываться.
|
||||||
`created_at`).
|
|
||||||
|
|
||||||
Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к
|
Сброс базиса система SHALL выполнять сохранением времени retry в поле задачи
|
||||||
существующему торренту, а не добавлять источник повторно вслепую; повторный
|
(`retried_at`, RFC 3339 UTC), которое приподнимает пол ОБОИХ страховочных мер
|
||||||
`Add` выполняется, только когда раздачи в qBittorrent нет.
|
(`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 выполнять **по типу источника**
|
Повторный `Add` при retry система SHALL выполнять **по типу источника**
|
||||||
(`source_type`), как и добавление пойманной загрузки (см. `download-tracking`
|
(`source_type`), как и добавление пойманной загрузки (см. `download-tracking`
|
||||||
@@ -138,11 +167,20 @@ Retry SHALL переводить задачу обратно в `downloading`,
|
|||||||
|
|
||||||
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
|
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
|
||||||
|
|
||||||
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
|
- **GIVEN** задача в `failed`, её торрент жив и здоров в qBittorrent
|
||||||
- **WHEN** пользователь нажимает retry в веб-UI
|
- **WHEN** пользователь нажимает retry в веб-UI
|
||||||
- **THEN** задача возвращается в `downloading` без повторного `Add`
|
- **THEN** задача возвращается в `downloading` без повторного `Add`
|
||||||
- **AND** не падает снова на ближайшем тике сверки по таймауту
|
- **AND** не падает снова на ближайшем тике сверки по таймауту
|
||||||
|
|
||||||
|
#### Scenario: Retry живого, но давно простаивающего торрента не падает снова
|
||||||
|
|
||||||
|
- **GIVEN** задача в `stuck`/`stalled`, её торрент жив в qBittorrent, но
|
||||||
|
добавлен давно и данные не двигались дольше `stuck_after`
|
||||||
|
- **WHEN** пользователь нажимает retry
|
||||||
|
- **THEN** задача возвращается в `downloading` без повторного `Add`
|
||||||
|
- **AND** на ближайшем тике сверки НЕ падает снова в `stuck` (базис сброшен
|
||||||
|
через `retried_at`)
|
||||||
|
|
||||||
#### Scenario: Retry доступен в Telegram
|
#### Scenario: Retry доступен в Telegram
|
||||||
|
|
||||||
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
|
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
|
||||||
@@ -157,6 +195,15 @@ Retry SHALL переводить задачу обратно в `downloading`,
|
|||||||
torrent сохранёнными байтами файлом
|
torrent сохранёнными байтами файлом
|
||||||
- **AND** задача переходит в `downloading`
|
- **AND** задача переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Retry сломанного живого торрента повторно отдаёт источник
|
||||||
|
|
||||||
|
- **GIVEN** задача в `failed`, её торрент присутствует в qBittorrent, но в
|
||||||
|
состоянии ошибки (`error`/`missingFiles`)
|
||||||
|
- **WHEN** пользователь инициирует retry
|
||||||
|
- **THEN** источник отдаётся заново (перецепка к сломанному торренту не
|
||||||
|
выполняется)
|
||||||
|
- **AND** задача переходит в `downloading`
|
||||||
|
|
||||||
#### Scenario: Retry torrent-загрузки без живого источника
|
#### Scenario: Retry torrent-загрузки без живого источника
|
||||||
|
|
||||||
- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent
|
- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent
|
||||||
@@ -364,3 +411,35 @@ SHALL отклоняться сразу с пояснением, что исто
|
|||||||
`nlink > 1`
|
`nlink > 1`
|
||||||
- **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым
|
- **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` их состояние не меняет
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user