download-tracking: требование о re-read source_type приведено к коду
- re-read `source_type` перечитывается под блокировкой после тик-снимка, а остаточное окно вывода имени названо известным ограничением с ценой и достоверным маршрутом восстановления (ручной шаг + `Retry`, не самоисцеление) - в `ingest` снята парная ложная гарантия «воркер добавит раздачу файлом», добавлены сценарии на оба окна апгрейда и на недоступные байты `.torrent` - заведён ADR о том, что при разрыве спека↔код двигается тот, чья формулировка сильнее рационали
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
# Спека следует за кодом, когда гарантия недостижима, а окно узкое
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-06
|
||||||
|
- **Источник:**
|
||||||
|
[openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/design.md](../../openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/design.md)
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Требование `download-tracking` о re-read `source_type` приведено **к коду**, а
|
||||||
|
не наоборот: перечитывать под блокировкой переходов **после тик-снимка**, а
|
||||||
|
остаточное окно (апгрейд magnet → `.torrent`, легший в вызов namer'а) названо в
|
||||||
|
спеке известным ограничением с ценой и маршрутом восстановления. Код не тронут.
|
||||||
|
|
||||||
|
Общее правило, которое отсюда следует для проекта: когда спека и код разошлись,
|
||||||
|
**двигается тот, чья формулировка сильнее рационали**. Требование, обещавшее
|
||||||
|
больше, чем нужно ради его собственной причины, чинится текстом; недостающая
|
||||||
|
гарантия чинится кодом.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Формулировка была сильнее своей же рационали:
|
||||||
|
|
||||||
|
> Рациональ исходного требования — «не полагаться на снимок, снятый ранее вне
|
||||||
|
> блокировки» — выполнен первым re-read под замком. Формулировка «непосредственно
|
||||||
|
> перед добавлением» была сильнее рационали и кодом не достигается: между
|
||||||
|
> re-read и `qbt.Add` стоит namer, вынесенный из-под блокировки намеренно
|
||||||
|
> (требование «Медленные вызовы SHALL выполняться вне блокировки»).
|
||||||
|
|
||||||
|
Цена починки кодом оказалась несоразмерной ущербу:
|
||||||
|
|
||||||
|
> Вариант A (пересобирать `addReq` из `before` под замком) отклонён: это не
|
||||||
|
> однострочник — `sourceAddParts` читает байты `.torrent` и держать его под
|
||||||
|
> блокировкой нельзя, а при апгрейде корректен был бы и повторный вызов namer'а
|
||||||
|
> (подсказка имени берётся из другого источника).
|
||||||
|
|
||||||
|
Главное же — **молчащая ложная гарантия дороже названного ограничения**. Пока
|
||||||
|
спека утверждала недостижимое, дефект был невидим ровно потому, что нормативный
|
||||||
|
дом поведения его отрицал; аудит capability находил его заново.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
- **A — починить код** (пересобирать запрос на добавление из свежей записи под
|
||||||
|
блокировкой). Отвергнут по цене: чтение блоба `.torrent` под замком
|
||||||
|
недопустимо, корректная версия тянет повторный вызов namer'а. Отвергнут
|
||||||
|
**отложенно, а не окончательно**: спека поэтому не запрещает его нормативно
|
||||||
|
(см. `design.md` D6).
|
||||||
|
- **B — привести спеку к коду** (принято). Наблюдаемое поведение прежнее,
|
||||||
|
меняется заявленное.
|
||||||
|
- **C — оставить как есть.** Отвергнут: расхождение спека↔код воспроизводится
|
||||||
|
каждым аудитом, а читатель спеки считает окно закрытым.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Нормативный дом поведения перестал утверждать недостижимое; ограничение
|
||||||
|
видно и имеет названную цену вместо молчания.
|
||||||
|
- `+` Парная ложная гарантия снята и в `ingest` («воркер добавит раздачу
|
||||||
|
файлом») — иначе она бы просто переехала в соседнюю capability и всплыла
|
||||||
|
следующим аудитом.
|
||||||
|
- `+` Заодно назван исход ветки «сохранённые байты `.torrent` недоступны», не
|
||||||
|
заказанной до этого ни одним сценарием.
|
||||||
|
- `−` Цена окна выше, чем считала постановка задачи: не «подождать и нажать
|
||||||
|
`Retry`», а ожидание `magnet_timeout` (дефолт `24h`) **плюс ручной шаг** —
|
||||||
|
убрать зависшую раздачу из qBittorrent. `Retry` сам не добивает: `metaDL`
|
||||||
|
считается живым и здоровым торрентом, и `Retry` к нему перецепляется без
|
||||||
|
повторного `add` (`design.md` D2). На этой пересмотренной цене вопрос
|
||||||
|
«чинить ли окно кодом» открыт заново.
|
||||||
|
- `−` Новая нормативная ветка (недоступные байты `.torrent`) держится на чтении
|
||||||
|
кода: теста-оракула у неё нет, её регрессия зелёный гейт не покрасит.
|
||||||
|
- `−` Прецедент «спека следует за кодом» опасен буквальным применением. Он
|
||||||
|
оправдан **только** когда формулировка сильнее рационали и ущерб от разрыва
|
||||||
|
назван; «код так делает, значит так и запишем» этой записью не
|
||||||
|
санкционируется.
|
||||||
|
|
||||||
|
## Триггер пересмотра
|
||||||
|
|
||||||
|
Записан отдельно, чтобы не гонять круг заново:
|
||||||
|
|
||||||
|
> Окно возвращается в работу вариантом A, когда апгрейд в вызове namer'а
|
||||||
|
> случится в эксплуатации хотя бы раз — признак в логах — либо когда стоимость
|
||||||
|
> ручного шага станет заметной. До того — принято и описано.
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
|
|
||||||
| Дата | Запись | Статус |
|
| Дата | Запись | Статус |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2026-08-06 | [Спека следует за кодом, когда гарантия недостижима, а окно узкое](ADR-2026-08-06-spec-follows-code-on-narrow-window.md) | — |
|
||||||
| 2026-08-04 | [Конвейер ревью и пайплайн задачи переезжают в плагины](ADR-2026-08-04-review-pipeline-to-plugin.md) | — |
|
| 2026-08-04 | [Конвейер ревью и пайплайн задачи переезжают в плагины](ADR-2026-08-04-review-pipeline-to-plugin.md) | — |
|
||||||
| 2026-07-24 | [Локальная сборка образа + доставка docker save/load](ADR-2026-07-24-local-image-build.md) | — |
|
| 2026-07-24 | [Локальная сборка образа + доставка docker save/load](ADR-2026-07-24-local-image-build.md) | — |
|
||||||
| 2026-07-23 | [Конвейер ревью: гейт, generative-проходы и триаж](ADR-2026-07-23-review-pipeline-generative.md) | — |
|
| 2026-07-23 | [Конвейер ревью: гейт, generative-проходы и триаж](ADR-2026-07-23-review-pipeline-generative.md) | — |
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-06
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Аудит capability `download-tracking` (сверка код↔спека после пачки
|
||||||
|
lifecycle-задач) нашёл расхождение между заявленным и фактическим.
|
||||||
|
|
||||||
|
Спека `openspec/specs/download-tracking/spec.md:139-143` требует перечитывать
|
||||||
|
`source_type` **под блокировкой переходов непосредственно перед добавлением**.
|
||||||
|
Код `processCatched` (`internal/worker/worker.go:442-517`) делает иначе:
|
||||||
|
|
||||||
|
1. `:447-450` — под `w.mu` перечитывает запись (`cur`) и проверяет
|
||||||
|
`state == catched`;
|
||||||
|
2. `:455` — вне замка собирает `addReq` из `cur` (`sourceAddParts` читает байты
|
||||||
|
`.torrent` из БД);
|
||||||
|
3. `:463-478` — вне замка зовёт namer (LLM, секунды);
|
||||||
|
4. `:484-491` — под `w.mu` перечитывает `before`, но проверяет только
|
||||||
|
`state == catched`; **`addReq` из `before` не пересобирается**;
|
||||||
|
5. `:501-511` — свежий листинг присутствия и `qbt.Add(addReq)`.
|
||||||
|
|
||||||
|
`UpgradeCatchedMagnetToTorrent` (`internal/store/download.go:392`) меняет
|
||||||
|
`source_type` magnet→torrent, пока задача в `catched`. Если апгрейд лёг в окно
|
||||||
|
шага 3, воркер добавит magnet-ссылку из снимка шага 1, хотя в БД уже
|
||||||
|
`source_type = torrent`.
|
||||||
|
|
||||||
|
Первое окно (между тик-снимком `ListDownloadsByState` и re-read шага 1) уже
|
||||||
|
закрыто и покрыто тестом `TestProcessCatchedReReadsSourceTypeUnderLock`.
|
||||||
|
|
||||||
|
Решение по задаче принято 2026-08-06: **вариант B — привести спеку к коду**.
|
||||||
|
Вариант A (пересобирать `addReq` из `before` под замком) отклонён: это не
|
||||||
|
однострочник — `sourceAddParts` читает байты `.torrent` и держать его под
|
||||||
|
блокировкой нельзя, а при апгрейде корректен был бы и повторный вызов namer'а
|
||||||
|
(подсказка имени берётся из другого источника).
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Требование спеки описывает **фактическое** поведение кода: re-read
|
||||||
|
`source_type` под блокировкой после тик-снимка, до подготовки запроса на
|
||||||
|
добавление.
|
||||||
|
- Остаточное окно (апгрейд в момент вызова namer'а) названо в спеке явно,
|
||||||
|
вместе с достоверным маршрутом восстановления. Читатель спеки не считает окно
|
||||||
|
закрытым и не «находит» его аудитом заново.
|
||||||
|
- Сценарии покрывают оба окна: апгрейд до re-read (закрыт) и апгрейд в окне
|
||||||
|
namer'а (открыт).
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Правки `internal/worker/worker.go` и любого другого кода. Наблюдаемое
|
||||||
|
поведение изменению не подлежит.
|
||||||
|
- Закрытие остаточного окна (вариант A). Заводится отдельной задачей, если окно
|
||||||
|
окажется реальной болью. Дельта поэтому не запрещает вариант A нормативно —
|
||||||
|
см. D6.
|
||||||
|
- Починка `error_code = qbit_add` у задачи, упавшей по недоступным байтам
|
||||||
|
`.torrent` (диагностика уводит не туда). Отдано урожаем — см. D7.
|
||||||
|
- Пересмотр `magnet_timeout`, `Retry` и правил восстановления — они живут в
|
||||||
|
`download-tracking` («Таймауты-предохранители downloading») и
|
||||||
|
`state-reconciliation` и здесь только цитируются.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1. Момент re-read формулируется как «после тик-снимка», а не «перед добавлением»
|
||||||
|
|
||||||
|
Рациональ исходного требования — «не полагаться на снимок, снятый ранее вне
|
||||||
|
блокировки» — выполнен первым re-read под замком. Формулировка «непосредственно
|
||||||
|
перед добавлением» была сильнее рационали и кодом не достигается: между re-read и
|
||||||
|
`qbt.Add` стоит namer, вынесенный из-под блокировки намеренно (требование
|
||||||
|
«Медленные вызовы SHALL выполняться вне блокировки»).
|
||||||
|
|
||||||
|
Альтернатива — оставить формулировку и починить код (вариант A) — отклонена
|
||||||
|
задачей: цена (namer под пересборкой, повторный вывод имени) выше ущерба от окна.
|
||||||
|
|
||||||
|
### D2. Маршрут восстановления в спеке называется точно, а не «Retry добьёт»
|
||||||
|
|
||||||
|
**Постановка задачи описывала восстановление как «`magnet_timeout` → `failed` →
|
||||||
|
ручной `Retry` перечитает актуальный `source_type` и добьёт». Проверка по коду
|
||||||
|
показала, что это верно не всегда**, и спека получит достоверную версию:
|
||||||
|
|
||||||
|
- зависший magnet стоит в qBittorrent в `metaDL`; `classify` относит `metaDL` к
|
||||||
|
`classDownloading` (`internal/worker/worker.go:1130-1142`), то есть торрент
|
||||||
|
«жив и здоров»;
|
||||||
|
- `Retry` при живом здоровом торренте ставит `reAdd = false`
|
||||||
|
(`internal/worker/worker.go:1043-1059`) — **перецепляется** к нему и повторный
|
||||||
|
`Add` по актуальному `source_type` не делает;
|
||||||
|
- фоновая сверка тоже не спасает: `metaDL` без метаданных — сценарий «Источник
|
||||||
|
так и не ожил» в `state-reconciliation`, задача остаётся в `failed`.
|
||||||
|
|
||||||
|
Достоверный маршрут: оператор убирает зависшую magnet-раздачу из qBittorrent,
|
||||||
|
после чего `Retry` не видит живого источника и добавляет заново **по актуальному
|
||||||
|
`source_type`** — байтами `.torrent` (они сохранены `UpgradeCatchedMagnetToTorrent`
|
||||||
|
и никуда не делись). Обходных путей нет: `Delete` из `failed` недоступен
|
||||||
|
(`state-reconciliation` «Полное удаление загрузки пользователем» — только
|
||||||
|
`done`/`orphaned`/`target_missing`), а повторный приём `.torrent` без удаления
|
||||||
|
раздачи заведёт задачу, которая усыновит тот же зависший `metaDL`-торрент.
|
||||||
|
|
||||||
|
Записать в спеку недостоверную версию нельзя: спека — нормативный дом поведения,
|
||||||
|
а неверное утверждение в ней и есть тот самый класс дефекта, который эта задача
|
||||||
|
закрывает.
|
||||||
|
|
||||||
|
Ущерб при этом не универсален: на публичном трекере или при живом DHT magnet
|
||||||
|
доберёт метаданные и задача пойдёт штатно. Спека это называет — иначе читатель
|
||||||
|
решит, что окно всегда ведёт в `failed`.
|
||||||
|
|
||||||
|
### D3. Окно называется ограничением с ценой, а не «допустимым навсегда»
|
||||||
|
|
||||||
|
Стоимость окна выше, чем предполагала постановка: не «подождать и нажать
|
||||||
|
Retry», а ожидание `magnet_timeout` (дефолт `24h`) плюс ручной шаг в
|
||||||
|
qBittorrent. Спека это называет; решение «чинить кодом или нет» остаётся
|
||||||
|
открытым вопросом задачи, потому что меняется цена, на которой B выбирался
|
||||||
|
против A.
|
||||||
|
|
||||||
|
Требование при этом не переезжает в другую capability: окно принадлежит
|
||||||
|
добавлению пойманной загрузки, а не восстановлению.
|
||||||
|
|
||||||
|
### D4. Два сценария вместо одного
|
||||||
|
|
||||||
|
Существующий сценарий на апгрейд между тик-снимком и re-read в спеке не записан
|
||||||
|
(есть только тест). Без него сценарий про открытое окно читался бы как «re-read
|
||||||
|
не работает вовсе». Поэтому фиксируются оба: закрытое окно с исходом «добавлено
|
||||||
|
файлом» и открытое с исходом «добавлен magnet, дальше предохранитель».
|
||||||
|
|
||||||
|
### D5. Правится и `ingest`, иначе ложная гарантия просто переезжает
|
||||||
|
|
||||||
|
Найдено ревью спек (профиль `design`). `ingest` «Приём источника из
|
||||||
|
`.torrent`-файла» обещает то же самое от своего лица: прозой — «Тем самым воркер
|
||||||
|
добавит раздачу файлом, а не magnet-хешем», и `AND`-строкой сценария «Апгрейд
|
||||||
|
catched-magnet до torrent» — «воркер добавит раздачу файлом (не по magnet)».
|
||||||
|
`GIVEN` этого сценария выполняется и в окне namer'а, а `THEN` — нет.
|
||||||
|
|
||||||
|
Оставить это значило бы не убрать ложную гарантию, а перенести её в соседнюю
|
||||||
|
capability: следующий аудит `ingest` воспроизвёл бы ту же находку, и цель
|
||||||
|
изменения («читатель спеки не считает окно закрытым») не была бы достигнута.
|
||||||
|
Поэтому change получает вторую дельту — на `ingest`: обещание обусловливается
|
||||||
|
(«если апгрейд лёг до подготовки запроса») и появляется сценарий на окно со
|
||||||
|
ссылкой на `download-tracking`. Нормативный дом ограничения при этом один —
|
||||||
|
`download-tracking`; `ingest` только не противоречит ему.
|
||||||
|
|
||||||
|
### D6. `SHALL NOT` не ставится там, где решение отложено
|
||||||
|
|
||||||
|
Найдено тем же ревью. Первая редакция дельты писала «пересобирать запрос под
|
||||||
|
блокировкой worker SHALL NOT» и «Восстановление SHALL требовать ручного шага».
|
||||||
|
`SHALL NOT` не откладывает, а **запрещает**: отложенный вариант A при реализации
|
||||||
|
оказался бы нарушением только что записанного требования, причём обоснование
|
||||||
|
запрета — деталь текущей реализации (`sourceAddParts` читает байты), а не
|
||||||
|
свойство поведения. Вариант A в форме «перечитать только `source_type` под
|
||||||
|
замком и при расхождении пропустить тик» медленных вызовов под блокировкой не
|
||||||
|
требует вовсе — и уже был бы отрезан.
|
||||||
|
|
||||||
|
Оба места переведены в описательный залог. `SHALL` остаётся только там, где
|
||||||
|
нормируется наблюдаемое поведение: re-read `source_type`, выбор способа
|
||||||
|
добавления, исход при недоступных байтах.
|
||||||
|
|
||||||
|
### D7. Названа ветка «байты `.torrent` недоступны»
|
||||||
|
|
||||||
|
Тем же ревью. Требование впервые называет шаг «подготовка запроса на
|
||||||
|
добавление», а его провал (`worker.go:455-461`) не заказан ни одним сценарием:
|
||||||
|
`WARN` и повтор на следующем тике. Раз шаг попал в текст, дешевле всего назвать
|
||||||
|
и его исход — добавлены предложение и сценарий.
|
||||||
|
|
||||||
|
За границами остался диагностический дефект той же ветки: устойчиво недоступные
|
||||||
|
байты доводят задачу до `catch_timeout` и она падает с `error_code = qbit_add`,
|
||||||
|
хотя qBittorrent ни при чём. Спека этого не узаконивает (про `error_code` в
|
||||||
|
добавленном тексте не сказано ни слова); находка отдана урожаем.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Спека узаконивает известный дефект]** → окно названо ограничением с
|
||||||
|
ценой и маршрутом восстановления, а не «так и надо»; вопрос о варианте A
|
||||||
|
записан и уходит владельцу задач. Дефект перестаёт быть невидимым — сейчас он
|
||||||
|
невидим ровно потому, что спека утверждает обратное.
|
||||||
|
- **[Формулировка «после тик-снимка» окажется слабее нужного]** → рациональ
|
||||||
|
(«не полагаться на снимок, снятый вне блокировки») в тексте требования
|
||||||
|
остаётся явной, поэтому будущий код, потерявший re-read вовсе, требованию
|
||||||
|
противоречит так же, как и раньше.
|
||||||
|
- **[Достоверный маршрут восстановления устареет вслед за `Retry`]** →
|
||||||
|
требование ссылается на `state-reconciliation` («Ручной повтор зависшей/упавшей
|
||||||
|
загрузки из транспортов»), а не переписывает его правила; изменится `Retry` —
|
||||||
|
сверка спека↔спека это поймает.
|
||||||
|
- **[Кода нет — гейт ничего не докажет]** → оракулы задачи это и предполагают:
|
||||||
|
`openspec validate --strict`, неизменность `internal/**` в диффе и то, что
|
||||||
|
существующий тест продолжает проходить без правок.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Спека `download-tracking` требует перечитывать `source_type` под блокировкой
|
||||||
|
переходов **непосредственно перед добавлением**, а `processCatched` перечитывает
|
||||||
|
запись под блокировкой сразу после тик-снимка — до вызова namer'а, который идёт
|
||||||
|
секунды вне блокировки. Апгрейд пойманной magnet-задачи до `.torrent`, легший
|
||||||
|
ровно в окно namer'а, оставляет `addReq` из устаревшего снимка: код добавит
|
||||||
|
magnet, хотя в БД уже `source_type = torrent`. Заявленное расходится с
|
||||||
|
фактическим, и аудит capability это поймал.
|
||||||
|
|
||||||
|
Расхождение закрывается приведением спеки к коду: рациональ требования — «не
|
||||||
|
полагаться на снимок, снятый ранее вне блокировки» — первым re-read уже
|
||||||
|
выполнен, а остаточное namer-окно принимается как **известное ограничение с
|
||||||
|
названной ценой**. Цена такая: на закрытом трекере magnet зависает в `metaDL`,
|
||||||
|
через `magnet_timeout` задача уходит в `failed` и сама оттуда не поднимается —
|
||||||
|
восстановление требует ручного шага (убрать зависшую раздачу из qBittorrent),
|
||||||
|
после которого `Retry` добавит сохранёнными байтами. Подробности маршрута и
|
||||||
|
почему «`Retry` добьёт сам» неверно — `design.md`, решение D2. Пока спека
|
||||||
|
утверждает недостижимую гарантию, следующий аудит поймает то же самое заново, а
|
||||||
|
читатель спеки будет считать окно закрытым.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Требование о re-read `source_type` переформулируется: перечитывать **под
|
||||||
|
блокировкой переходов после тик-снимка**, перед подготовкой запроса на
|
||||||
|
добавление, — вместо «непосредственно перед добавлением».
|
||||||
|
- Остаточное окно между этим re-read и `qbt.Add` (вывод имени через LLM) явно
|
||||||
|
названо известным ограничением, с достоверным маршрутом восстановления: на
|
||||||
|
публичном трекере ущерба нет вовсе; на закрытом magnet зависает в `metaDL`,
|
||||||
|
предохранитель `magnet_timeout` уводит в `failed`, откуда задача сама не
|
||||||
|
поднимается — нужен ручной шаг (убрать зависшую раздачу), после которого
|
||||||
|
`Retry` добавит сохранёнными байтами.
|
||||||
|
- Добавляется сценарий на апгрейд в окне namer'а с исходом «добавлен magnet из
|
||||||
|
снимка → `magnet_timeout` → `failed` → ручной шаг + `Retry`»; существующий
|
||||||
|
сценарий на апгрейд между тик-снимком и re-read фиксируется явно.
|
||||||
|
- Называется исход ветки «сохранённые байты `.torrent` недоступны»: `add` не
|
||||||
|
выполняется, загрузка остаётся в `catched`, отсечка — `catch_timeout`.
|
||||||
|
Требование впервые называет шаг подготовки запроса, и его провал до сих пор не
|
||||||
|
был заказан ни одним сценарием.
|
||||||
|
- В `ingest` снимается парная ложная гарантия: обещание «воркер добавит раздачу
|
||||||
|
файлом (не по magnet)» обусловливается тем, что апгрейд лёг до подготовки
|
||||||
|
запроса, и получает сценарий на окно со ссылкой на `download-tracking`. Иначе
|
||||||
|
ложная гарантия не убирается, а переезжает в соседнюю capability.
|
||||||
|
|
||||||
|
Изменение не ломающее: наблюдаемое поведение системы прежнее, меняется только
|
||||||
|
заявленное.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `download-tracking`: требование «Добавление пойманной загрузки в qBittorrent»
|
||||||
|
— момент re-read `source_type` относительно тик-снимка и namer'а, признание
|
||||||
|
остаточного окна с его ценой и маршрутом восстановления, исход при
|
||||||
|
недоступных байтах, три новых сценария.
|
||||||
|
- `ingest`: требование «Приём источника из `.torrent`-файла» — обещание «воркер
|
||||||
|
добавит раздачу файлом» обусловливается моментом апгрейда, добавляется
|
||||||
|
сценарий на окно.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `openspec/specs/download-tracking/spec.md` — текст требования и сценарии.
|
||||||
|
- `openspec/specs/ingest/spec.md` — обусловленное обещание и сценарий на окно.
|
||||||
|
- `internal/worker/worker.go` (`processCatched`) — только чтение как источник
|
||||||
|
фактического поведения; правок нет.
|
||||||
|
- `internal/worker/catched_test.go` — `TestProcessCatchedReReadsSourceTypeUnderLock`
|
||||||
|
остаётся оракулом первого re-read; правок нет.
|
||||||
|
- Кода, схемы БД, конфига и внешних контрактов изменение не касается.
|
||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
# Триаж ревью: change `catched-source-type-reread-wording`
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
- **Профиль:** `quick`. **Режим прогона:** по графу. Второй чекпоинт (после
|
||||||
|
apply, до archive). Изменение — только спеки и артефакты change, ни строки
|
||||||
|
кода: критерий 4.3 подтверждён гейтом (`git diff --stat master` пусто по
|
||||||
|
`internal/`).
|
||||||
|
- **Гейт:** зелёный. Кодовые шаги — `SKIP` по составу диффа (нет `.go`),
|
||||||
|
`canon` и `gitleaks` — `OK`. `openspec validate --strict` valid. 12 тестов
|
||||||
|
`internal/worker` PASS независимым прогоном.
|
||||||
|
- **Проходы поимённо:**
|
||||||
|
1. `review-gate` — отработал, находок 0 (зелёный, три позитивных оракула).
|
||||||
|
2. `review-specs` — отработал, 4 находки (все `minor`).
|
||||||
|
3. `review-code` — отработал, 1 находка (`minor`).
|
||||||
|
4. `review-triage` — этот отчёт.
|
||||||
|
- Состав сверен с профилем `quick`: расхождений нет, все проходы профиля
|
||||||
|
отработали. Непущенные стадии — см. «Границы покрытия».
|
||||||
|
- **Баланс находок:** 5 на входе → 4 пункта на выходе (находки 2 и 4 слиты по
|
||||||
|
одной причине — обе суть неполное проведение решений D5/D6 в тексте дельты).
|
||||||
|
Молчком не выброшено ничего.
|
||||||
|
- **Независимая перепроверка триажа** (запрошена явно): полнота копий обеих
|
||||||
|
`MODIFIED`-дельт сверена своим `diff -u` тел требований база↔дельта
|
||||||
|
(`download-tracking` «Добавление пойманной загрузки в qBittorrent», `ingest`
|
||||||
|
«Приём источника из `.torrent`-файла»). Все ханки — добавления либо
|
||||||
|
заявленные переформулировки; **ни один сценарий и ни одна нормативная клауза
|
||||||
|
базы не потеряны**. Заявление проходов о полноте подтверждено.
|
||||||
|
- **Ложноположительные:** все 4 пункта сверены со списком «Типовые
|
||||||
|
ложноположительные» `docs/review.md` — совпадений нет.
|
||||||
|
- **Согласие проходов:** пересекающихся находок нет, каждый проход нашёл своё;
|
||||||
|
приоритет по совпадению не поднимался.
|
||||||
|
|
||||||
|
## Блокирует мердж
|
||||||
|
|
||||||
|
Пусто. Инварианты `CLAUDE.md` диффом не затрагиваются (дифф не содержит кода и
|
||||||
|
не ослабляет ни одно `SHALL` про источник, последнюю копию, песочницу
|
||||||
|
библиотеки, перезапись). Кандидатов в `critical`/`major` не было ни на входе,
|
||||||
|
ни после добычи оракулов.
|
||||||
|
|
||||||
|
## Стоит исправить сейчас
|
||||||
|
|
||||||
|
### 1. Архивируемый `proposal.md` и файл задачи сохраняют опровергнутый маршрут «самоисцеления» — следующий читатель повторит ровно тот дефект, ради которого change заведён
|
||||||
|
|
||||||
|
- Файл: `openspec/changes/catched-source-type-reread-wording/proposal.md:13-14,24-26`; `docs/tasks/items/catched-source-type-refresh.md:5,31-32,42-43`
|
||||||
|
- Severity: minor
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: добыт грепом при триаже. `proposal.md:13` — «остаточное namer-окно
|
||||||
|
**самоисцеляется** (`magnet_timeout` → `failed` → ручной `Retry` перечитает
|
||||||
|
актуальный `source_type`)»; `:26` — «ручной `Retry` **добивает** уже с
|
||||||
|
актуальным `source_type`». Собственная дельта того же change
|
||||||
|
(`specs/download-tracking/spec.md:73-82`) и `design.md` D2 утверждают
|
||||||
|
обратное: «Сама собой задача из этого состояния не восстанавливается…
|
||||||
|
`Retry` при живом торренте перецепляется… Восстановление сегодня требует
|
||||||
|
ручного шага». Код: `worker.go:1043-1059` (`reAdd = !alive`),
|
||||||
|
`classify:1130-1142` (`metaDL` → жив), тест `TestRetryReattachesNoReadd`.
|
||||||
|
Тот же устаревший текст — в файле задачи.
|
||||||
|
- Последствие: после archive `proposal.md` остаётся постоянным документом о
|
||||||
|
намерении и прямо противоречит своей же дельте. Читатель возьмёт дешёвый
|
||||||
|
маршрут «просто Retry», не сделает ручной шаг (убрать зависшую раздачу из
|
||||||
|
qBittorrent), задача останется в `failed`, читатель решит, что «спека врёт»,
|
||||||
|
и заведёт находку заново — зеркальная копия дефекта, который change
|
||||||
|
закрывает.
|
||||||
|
- Предложение: привести `## Why` и второй буллет `## What Changes` в
|
||||||
|
`proposal.md` к формулировке дельты (ссылкой на `design.md` D2); тем же
|
||||||
|
движением поправить «Насколько больно»/«Решение» в
|
||||||
|
`docs/tasks/items/catched-source-type-refresh.md`. Заметка-отклонение в
|
||||||
|
`tasks.md:60-65` уже честная — её не трогать.
|
||||||
|
- Найдено проходом: `review-specs`
|
||||||
|
- Действие: инлайн
|
||||||
|
|
||||||
|
### 2. Новая нормативная ветка «байты `.torrent` недоступны» не имеет оракула: её регрессия пройдёт зелёный гейт и молча сделает свежезаписанное требование ложным
|
||||||
|
|
||||||
|
- Файл: `openspec/changes/catched-source-type-reread-wording/specs/download-tracking/spec.md:55-57,198-205`; код — `internal/worker/worker.go:455-461`
|
||||||
|
- Severity: minor
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: перепроверен триажем. `grep "^func Test" internal/worker/catched_test.go`
|
||||||
|
— 12 тестов, ветки `prepErr != nil` (worker.go:456-459: `add` не зовётся,
|
||||||
|
`WARN`, задача остаётся в `catched`) нет ни в одном. Ближайший
|
||||||
|
`TestRetryTorrentMissingBytesRollsBack` покрывает другой путь (`retryDownload`,
|
||||||
|
`:1074-1082` — откат в прежнее состояние, не удержание в `catched`).
|
||||||
|
- Последствие: дельта впервые делает исход ветки нормативным (`add` SHALL NOT,
|
||||||
|
SHALL оставаться в `catched`), но исход держится только на чтении кода.
|
||||||
|
Будущая правка, уводящая недоступность байтов сразу в `failed`, не уронит ни
|
||||||
|
один тест — класс «молчание»: требование станет ложным незаметно.
|
||||||
|
- Предложение: развилка, готовый вопрос владельцу: «Ветке "байты недоступны"
|
||||||
|
нужен оракул, но тест — правка под `internal/`, запрещённая критерием 4.3.
|
||||||
|
Варианты: (а) завести follow-up задачу на
|
||||||
|
`TestProcessCatchedMissingTorrentBytesKeepsCatched` после archive — цена:
|
||||||
|
одна маленькая задача, ветка получает оракул; (б) принять отсутствие оракула
|
||||||
|
и записать его явно в границы дельты/журнал — цена: регрессия ветки молчалива
|
||||||
|
до ручного разбора; (в) расширить критерий 4.3 этого change и добавить тест
|
||||||
|
сейчас — цена: пересмотр критерия приёмки человеком.»
|
||||||
|
- Найдено проходом: `review-specs`
|
||||||
|
- Действие: развилка
|
||||||
|
|
||||||
|
### 3. Оговорки об окне читаются нормативно вопреки D5/D6: `ingest`-сценарий нормирует поведение воркера, а обоснование в `download-tracking` подводит чтение блоба под запрет медленных вызовов
|
||||||
|
|
||||||
|
- Файл: `openspec/changes/catched-source-type-reread-wording/specs/ingest/spec.md:104-112`; `specs/download-tracking/spec.md:63-65`
|
||||||
|
- Severity: minor
|
||||||
|
- Confidence: medium
|
||||||
|
- Оракул: `design.md` дословно. D5: «Нормативный дом ограничения при этом один —
|
||||||
|
`download-tracking`; `ingest` только не противоречит ему» — но `THEN`/`AND`
|
||||||
|
нового `ingest`-сценария утверждают исход добавления позитивно, а сценарий и
|
||||||
|
есть проверяемая единица требования. D6: «обоснование запрета — деталь текущей
|
||||||
|
реализации, а не свойство поведения. Вариант A в форме "перечитать только
|
||||||
|
`source_type` под замком…" медленных вызовов под блокировкой не требует
|
||||||
|
вовсе» — но дельта (`:63-65`) связывает «не пересобирается» причинной связкой
|
||||||
|
с правилом о медленных вызовах, хотя сама же перечисляет медленные вызовы
|
||||||
|
поимённо («вывод имени через LLM, `qbt.Add`», `:89`), и локальные чтения БД
|
||||||
|
под `w.mu` штатны (`worker.go:448,485,533`).
|
||||||
|
- Последствие: два симметричных исхода одного корня. (а) В день реализации
|
||||||
|
варианта A (назван отложенным, не отменённым) `download-tracking` поправят
|
||||||
|
гарантированно, `ingest`-сценарий вспомнят отдельно — забытый, он станет
|
||||||
|
требованием, прямо противоречащим коду: зеркальная копия исходного дефекта.
|
||||||
|
(б) Автор, взявшийся закрывать окно, прочтёт причинную связку как нормативное
|
||||||
|
препятствие дешёвому варианту — ровно исход, который D6 хотел исключить.
|
||||||
|
- Предложение: в `ingest`-сценарии свести `THEN`/`AND` к тому, что нормирует
|
||||||
|
приём (байты сохранены, `source_type` стал `torrent`), исход добавления
|
||||||
|
оставить ссылкой на `download-tracking`; в `download-tracking:63-65` убрать
|
||||||
|
причинную связку с правилом о медленных вызовах, оставив факт («сегодня
|
||||||
|
запрос под блокировкой не пересобирается; подробности решения — вне этого
|
||||||
|
требования»).
|
||||||
|
- Найдено проходом: `review-specs` (две находки, слиты триажем: одна причина —
|
||||||
|
неполное проведение D5/D6 в тексте)
|
||||||
|
- Действие: инлайн
|
||||||
|
|
||||||
|
### 4. Цитата требования `state-reconciliation` усечена — при сверке спека↔спека, которую change сам называет своим механизмом защиты, ссылка не найдётся текстовым поиском
|
||||||
|
|
||||||
|
- Файл: `openspec/changes/catched-source-type-reread-wording/specs/download-tracking/spec.md:77-78`; `design.md:175-176`
|
||||||
|
- Severity: minor
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: перепроверен триажем грепом. Фактический заголовок —
|
||||||
|
`openspec/specs/state-reconciliation/spec.md:136`: «Ручной повтор
|
||||||
|
зависшей/упавшей загрузки **из транспортов**». Оба места change цитируют без
|
||||||
|
«из транспортов». Все пять прежних ссылок в архиве
|
||||||
|
(`openspec/changes/archive/…`) приводят заголовок целиком — усечение появилось
|
||||||
|
только здесь.
|
||||||
|
- Последствие: `design.md` Risks прямо полагается на «сверка спека↔спека это
|
||||||
|
поймает» при изменении `Retry` — а неточная цитата дословным поиском не
|
||||||
|
находится, и защита, на которую change ссылается, для этой пары не работает.
|
||||||
|
- Предложение: дописать «из транспортов» в обеих цитатах.
|
||||||
|
- Найдено проходом: `review-code`
|
||||||
|
- Действие: инлайн
|
||||||
|
|
||||||
|
## Гипотезы без доказательства
|
||||||
|
|
||||||
|
Пусто. Понижений не было: `critical`/`major` на входе не было, все четыре
|
||||||
|
оставшихся пункта имеют добытый оракул (греп/дифф/дословный текст design), у
|
||||||
|
двух `Confidence: medium` — они и так `minor`.
|
||||||
|
|
||||||
|
## Promote candidates
|
||||||
|
|
||||||
|
1. **Дословность цитат заголовков требований при кросс-ссылках между
|
||||||
|
capability — механизируемо.** Из находки 4: проверка «каждая цитата
|
||||||
|
`<capability> «Заголовок»` существует дословно в
|
||||||
|
`openspec/specs/<capability>/spec.md`» — кандидат в шаг `canon`
|
||||||
|
(`docs.py check`) или отдельный шаг гейта. Правило текстовое, оракул
|
||||||
|
объективный, спорить не о чем.
|
||||||
|
2. **Термин «тик-снимок».** Дельта вводит компактную форму, которой нет в
|
||||||
|
базовых спеках («снимок списка `catched`»). Находкой не поднято: единого
|
||||||
|
глоссария нет, долг признан задачей
|
||||||
|
`docs/tasks/items/ubiquitous-language-glossary.md` — приписать термин туда.
|
||||||
|
(Провенанс: `review-code`.)
|
||||||
|
|
||||||
|
## Урожай
|
||||||
|
|
||||||
|
Отложенные находки — владельцу задач (`docs/tasks/`, скилл `av-dev-pm:tasks`),
|
||||||
|
списком:
|
||||||
|
|
||||||
|
1. **Диагностика ветки недоступных байтов уводит не туда**: устойчиво
|
||||||
|
недоступные байты `.torrent` доводят задачу до `catch_timeout`, и она падает
|
||||||
|
с `error_code = qbit_add`, хотя qBittorrent ни при чём. Оракул: `design.md`
|
||||||
|
D7 (сознательно отдано урожаем, дельта этого не узаконивает). Провенанс:
|
||||||
|
ревью предложения (профиль `design`) → `design.md` D7, подтверждено
|
||||||
|
`review-specs`.
|
||||||
|
2. **Закрывать ли окно namer'а кодом (вариант A)** — открытый вопрос задачи с
|
||||||
|
пересмотренной ценой: не «подождать и Retry», а `magnet_timeout` (дефолт
|
||||||
|
`24h`) плюс ручной шаг в qBittorrent. Цена, на которой B выбирался против A,
|
||||||
|
изменилась — вопрос владельцу. Оракул: `design.md` D2/D3. Провенанс:
|
||||||
|
`design.md` D3, `review-specs` находка об окне.
|
||||||
|
3. **`catch_timeout = 0` не даёт отсечки**: спека обещает предохранитель
|
||||||
|
безусловно, код проверяет только при `w.cfg.CatchTimeout > 0`
|
||||||
|
(`worker.go:427`), дефолт `10m`. Пред-существующее, вне scope этого change.
|
||||||
|
Провенанс: `review-specs`, границы спеки.
|
||||||
|
4. **Отмена `context` посреди `processCatched`** — пред-существующая
|
||||||
|
недоговорённость спеки (что остаётся при обрыве между шагами). Провенанс:
|
||||||
|
`review-specs`, границы спеки.
|
||||||
|
5. **Связка «failed + повторный приём `.torrent` → усыновление зависшей
|
||||||
|
раздачи» нормирована прозой, сквозного оракула нет** (проверка размазана по
|
||||||
|
двум capability, теста на цепочку целиком нет). Провенанс: `review-specs`,
|
||||||
|
границы спеки.
|
||||||
|
6. **Верхняя граница окна в тексте — момент `qbt.Add`, а сценарий называет
|
||||||
|
только «окно вывода имени»**: интервал `worker.go:484-511` (state re-read →
|
||||||
|
листинг → `add`, `addReq` не пересобирается) в тексте дельты не разделён.
|
||||||
|
Кандидат на уточнение формулировки при следующей правке требования.
|
||||||
|
Провенанс: `review-specs`, границы спеки.
|
||||||
|
|
||||||
|
## Границы покрытия
|
||||||
|
|
||||||
|
**Запущено** (профиль `quick`, режим «по графу»):
|
||||||
|
|
||||||
|
- `review-gate` — отработал, зелёный. Кодовые шаги (build, vet, lint, gofmt,
|
||||||
|
test, flaky, race, govulncheck, diff-coverage, migrations) — `SKIP` по
|
||||||
|
причине «нет изменений в `.go`/`go.mod`»: корректный пропуск по составу
|
||||||
|
диффа, не дыра инструментария. Race-детектор не запускался вовсе —
|
||||||
|
конкурентность диффом не тронута. Вне чартера гейта — смысловая
|
||||||
|
согласованность формулировок дельта-спек.
|
||||||
|
- `review-specs` — отработал, 4 находки. Не мог проверить в принципе: поведение
|
||||||
|
под реальным потоком, живой qBittorrent (в т.ч. фактическую реакцию его
|
||||||
|
версий на дубль `add`), качество распознавания. Свои границы спеки назвал —
|
||||||
|
разнесены в «Урожай» (пп. 3-6).
|
||||||
|
- `review-code` — отработал, 1 находка. `docs/conventions/*` построчно не
|
||||||
|
читались за пределами двух вопросов проекта: их предмет (код) в диффе
|
||||||
|
отсутствует буквально — честная пустота по объекту, не пробел. Оба вопроса
|
||||||
|
проекта к `code` — неприменимы (кода и полей конфига в диффе нет).
|
||||||
|
- `review-triage` — этот отчёт. Ничего нового не находит по определению:
|
||||||
|
работает с чужими выводами; пропуск любого прохода — его пропуск тоже.
|
||||||
|
Дополнительно выполнено: независимый `diff -u` полноты `MODIFIED` (потерь
|
||||||
|
нет), грепы-оракулы находок 1, 2, 4.
|
||||||
|
|
||||||
|
**Не запускались** (все — по причине «профиль `quick`: дифф не содержит кода,
|
||||||
|
поведение снаружи не меняется, новых понятий и правил
|
||||||
|
идентичности/слияния/разбора не вводится»):
|
||||||
|
|
||||||
|
- `review-adversary` (стадия 2) — не запускался. Значит, вопросы проекта к
|
||||||
|
`adversary` (пути вне библиотеки, снятие последней копии, крафт-магнет) в
|
||||||
|
этом прогоне никто не задавал — к диффу из одних спек они и не применимы.
|
||||||
|
- `review-ops` (стадия 2) — не запускался. Гонки рассуждением не проверялись —
|
||||||
|
и не требовались: конкурентный код не тронут.
|
||||||
|
- `review-reimpl` (стадия 3) — не запускался.
|
||||||
|
- `review-architecture` (стадия 4) — не запускался. Вопрос «не появился ли
|
||||||
|
второй способ» в этом прогоне не задан.
|
||||||
|
- `review-rubric` — не запускался.
|
||||||
|
|
||||||
|
**Осталось целиком на человеке** — `docs/review.md`, «Недоступно проверке»,
|
||||||
|
двумя отдельными списками.
|
||||||
|
|
||||||
|
*Не проверит ни один проход* (принципиальная граница):
|
||||||
|
|
||||||
|
- история инцидентов на umbar и то, что уже ломалось в проде;
|
||||||
|
- поведение SQLite под реальным объёмом и профилем нагрузки;
|
||||||
|
- завязка внешних потребителей (Jellyfin, закладки, чужие ссылки) на текущее
|
||||||
|
поведение;
|
||||||
|
- качество распознавания как таковое (размеченный корпус решено не собирать —
|
||||||
|
`docs/tasks/REJECTED.md`, 2026-08-06);
|
||||||
|
- суждение «этой функциональности не должно существовать».
|
||||||
|
|
||||||
|
*Перестали проверять сознательно* (пересматривается первым при промахе):
|
||||||
|
|
||||||
|
- **идиоматичность Go — с 2026-08-04**: проход `idiom` упразднён при переезде
|
||||||
|
на плагин `av-dev-pipeline`; различение «идиоматично против распространено»
|
||||||
|
не спрашивает никто; пересмотр — задача `quality-review-agents`. Для этого
|
||||||
|
диффа предмета нет (кода нет), но класс остаётся неснятым для проекта.
|
||||||
|
|
||||||
|
Плюс общее: поведение под реальным потоком, поведение внешних систем в их
|
||||||
|
боевых версиях (qBittorrent, трекеры, DHT — весь маршрут восстановления из D2
|
||||||
|
проверен чтением кода, не живым прогоном: запрет «не ходить в боевой
|
||||||
|
qBittorrent» соблюдён), вопрос нужности функциональности.
|
||||||
|
|
||||||
|
**Документы проекта:**
|
||||||
|
|
||||||
|
- `CLAUDE.md` (инварианты с severity) — есть, прочитан; ни одна находка
|
||||||
|
инвариантов не трогает, основание `critical` не понадобилось.
|
||||||
|
- `docs/review.md` — есть; «Типовые ложноположительные» сверены (совпадений
|
||||||
|
нет), «Недоступно проверке» перенесено выше двумя списками. **Журнал
|
||||||
|
дефектов пуст** (заведён 2026-07-23) — оракулов вида «этот класс здесь уже
|
||||||
|
воспроизводился» не существует, подтверждение по журналу было недоступно
|
||||||
|
всем проходам.
|
||||||
|
- `docs/security.md`, `docs/architecture.md` — есть; находкам не потребовались.
|
||||||
|
- Единого глоссария терминов в проекте нет (долг признан задачей
|
||||||
|
`ubiquitous-language-glossary`) — из-за этого «тик-снимок» не поднят
|
||||||
|
находкой, а отдан в Promote candidates.
|
||||||
|
- Недостающих документов ни один проход не заявил.
|
||||||
|
|
||||||
|
**Потолок:** не потребовал жертв — 5 находок на входе, 4 пункта на выходе
|
||||||
|
(слияние по причине, не отсев); молчком не выброшено ничего.
|
||||||
+279
@@ -0,0 +1,279 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Добавление пойманной загрузки в qBittorrent
|
||||||
|
|
||||||
|
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
|
||||||
|
подхватывать загрузки в состоянии `catched` и для каждой (кроме случая уже
|
||||||
|
присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из
|
||||||
|
контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить
|
||||||
|
источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и
|
||||||
|
перевести загрузку `catched → downloading`. Отдельного состояния между `catched`
|
||||||
|
и `downloading` быть SHALL NOT — успешный `add` сразу переводит в `downloading`
|
||||||
|
(которое и означает «в qBit, возможно `metaDL`»).
|
||||||
|
|
||||||
|
Перед добавлением worker SHALL проверять, **присутствует ли торрент загрузки уже
|
||||||
|
в qBittorrent** (по любому из её infohash), опираясь на листинг раздач того же
|
||||||
|
тика. Если торрент уже присутствует, worker SHALL **усыновить** его: перевести
|
||||||
|
загрузку `catched → downloading` **без повторного `add`** и без вывода имени
|
||||||
|
через LLM (`display_name` берётся из имени присутствующей раздачи). Повторный
|
||||||
|
`add` здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. `409
|
||||||
|
Conflict`), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше
|
||||||
|
идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL
|
||||||
|
выполняться **до вывода отображаемого имени**, чтобы не тратить LLM-вызов на
|
||||||
|
загрузку, которую добавлять не требуется.
|
||||||
|
|
||||||
|
Инвариант приёма («одна активная загрузка на infohash», см. `ingest`) гарантирует,
|
||||||
|
что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой
|
||||||
|
активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как
|
||||||
|
«усыновить и разложить», а не как конфликт с чужой задачей.
|
||||||
|
|
||||||
|
Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную
|
||||||
|
загрузку в этот тик трогать SHALL NOT (ни `add`, ни namer) и повторить на
|
||||||
|
следующем; устойчивая недоступность отсекается предохранителем `catch_timeout`
|
||||||
|
(см. «Предохранитель зависшего catched»).
|
||||||
|
|
||||||
|
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
|
||||||
|
(`source_type`):
|
||||||
|
|
||||||
|
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
|
||||||
|
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
|
||||||
|
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
|
||||||
|
загрузке при приёме) и передавать их **файлом** (`torrents` API
|
||||||
|
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
|
||||||
|
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
|
||||||
|
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
||||||
|
magnet-хешу вместо файла система SHALL NOT.
|
||||||
|
|
||||||
|
`source_type` для выбора способа добавления worker SHALL перечитывать **под
|
||||||
|
блокировкой переходов после тик-снимка** — перед подготовкой запроса на
|
||||||
|
добавление, а не полагаясь на снимок списка `catched`, который к моменту
|
||||||
|
обработки загрузки уже устарел (блокировка между снятием списка и обработкой
|
||||||
|
отпускается). Иначе апгрейд пойманной magnet-задачи до `.torrent` (см.
|
||||||
|
`ingest`), легший между снятием списка и обработкой, был бы пропущен, и worker
|
||||||
|
добавил бы magnet из устаревшего снимка, хотя в БД уже `torrent`.
|
||||||
|
|
||||||
|
Если подготовка запроса не удалась (сохранённые байты `.torrent` недоступны),
|
||||||
|
worker `add` выполнять SHALL NOT и SHALL оставлять загрузку в `catched` для
|
||||||
|
повтора на следующем тике; отсечка — `catch_timeout`.
|
||||||
|
|
||||||
|
Между этим re-read и самим `add` остаётся окно: вывод отображаемого имени идёт
|
||||||
|
секунды вне блокировки (см. ниже), а запрос на добавление собран до него и по
|
||||||
|
свежей записи не пересобирается. Апгрейд magnet → `.torrent`, легший ровно в это
|
||||||
|
окно, worker не подхватывает и добавляет magnet-ссылку. Это **известное
|
||||||
|
ограничение с названной ценой**, а не гарантия: сегодня запрос собран до вывода
|
||||||
|
имени и по свежей записи не пересобирается. Закрывать ли окно кодом и каким
|
||||||
|
способом (пересборкой запроса или сравнением одного `source_type` под замком с
|
||||||
|
пропуском тика при расхождении) — открытый вопрос за границами этого
|
||||||
|
требования.
|
||||||
|
|
||||||
|
Ущерб окна ограничен, но не бесплатен, и спека называет его точно. На публичном
|
||||||
|
трекере (или при живом DHT) magnet доберёт метаданные и задача пойдёт штатно —
|
||||||
|
ущерба нет вовсе. На закрытом трекере, ради которого `.torrent` и подавался,
|
||||||
|
magnet без метаданных зависает в `metaDL`, и предохранитель `magnet_timeout`
|
||||||
|
уводит задачу в `failed` (см. «Таймауты-предохранители downloading»). Сама собой
|
||||||
|
задача из этого состояния не восстанавливается: фоновая сверка оживляет только
|
||||||
|
продвинувшийся источник, а `Retry` при живом и здоровом торренте (`metaDL`
|
||||||
|
считается живым) перецепляется к нему и повторный `add` по актуальному
|
||||||
|
`source_type` не делает — см. `state-reconciliation` «Ручной повтор
|
||||||
|
зависшей/упавшей загрузки из транспортов». Восстановление сегодня требует
|
||||||
|
ручного шага: убрать
|
||||||
|
зависшую magnet-раздачу из qBittorrent, после чего `Retry` добавит источник
|
||||||
|
заново по актуальному `source_type` — сохранёнными байтами, которые апгрейд уже
|
||||||
|
записал. Без этого шага повторный приём `.torrent` не помогает: новая загрузка
|
||||||
|
усыновит ту же зависшую раздачу (см. «усыновить» выше).
|
||||||
|
|
||||||
|
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
|
||||||
|
загрузку в `catched` для повторной попытки на следующем тике; переход в
|
||||||
|
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
|
||||||
|
естественными тиками поллинга, отсечка — `catch_timeout`).
|
||||||
|
|
||||||
|
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
|
||||||
|
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
|
||||||
|
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
|
||||||
|
downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией,
|
||||||
|
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
|
||||||
|
параллельной отмене).
|
||||||
|
|
||||||
|
Вывод имени (LLM) занимает секунды и идёт вне блокировки, поэтому загрузку могут
|
||||||
|
отменить (`catched → cancelled`) в это окно. Чтобы отменённая задача не оставила
|
||||||
|
неуправляемый торрент в qBittorrent, worker SHALL применять комбинированную
|
||||||
|
защиту. Порядок шагов относительно блокировки переходов: `[под блокировкой]`
|
||||||
|
re-read состояния → `[вне блокировки]` свежий листинг присутствия → `[вне
|
||||||
|
блокировки]` `add` → `[под блокировкой]` запись перехода и (при неуспехе) re-read
|
||||||
|
состояния для решения об уборке → `[вне блокировки]` удаление. Сетевые вызовы
|
||||||
|
(листинг, `add`, удаление) под блокировкой держаться SHALL NOT.
|
||||||
|
|
||||||
|
- **Re-read состояния перед `add`.** Непосредственно перед `qbt.Add` (после
|
||||||
|
вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если
|
||||||
|
она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик
|
||||||
|
пропустить. Это сужает окно гонки до промежутка между re-read и записью
|
||||||
|
перехода. Этот re-read проверяет **только состояние** и запрос на добавление не
|
||||||
|
пересобирает — за `source_type` отвечает более ранний re-read после
|
||||||
|
тик-снимка (см. выше).
|
||||||
|
|
||||||
|
- **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add`
|
||||||
|
worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с
|
||||||
|
одним из infohash загрузки ещё НЕТ. Если этот листинг **не удался** (сетевой
|
||||||
|
сбой), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить (повтор
|
||||||
|
на следующем): без подтверждённого отсутствия признак «своё/чужое» неизвестен,
|
||||||
|
и последующее удаление-с-данными было бы небезопасным. Если торрент уже
|
||||||
|
присутствует (внешний клиент/пользователь добавил тот же infohash в окно
|
||||||
|
гонки), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить — на
|
||||||
|
следующем тике её усыновит ветка «уже присутствует». Подтверждённое отсутствие
|
||||||
|
непосредственно-перед-`add` SHALL служить признаком того, что торрент,
|
||||||
|
оказавшийся под этим infohash сразу после `add`, создан именно этим `add` (наш
|
||||||
|
артефакт), а не пред-существовал.
|
||||||
|
|
||||||
|
- **Уборка добавленного торрента при отмене в окне после `add`.** Если `add`
|
||||||
|
прошёл успешно, а последующая запись перехода `PromoteCatched` не применилась,
|
||||||
|
worker SHALL принимать решение об уборке по **свежему re-read состояния под
|
||||||
|
блокировкой**, а не по факту ошибки промоушена: неуспех промоушена бывает и
|
||||||
|
из-за отмены (`state` уже не `catched`), и из-за транзиентного сбоя хранилища
|
||||||
|
(`state` всё ещё `catched`, задача жива). Только при подтверждённом `state !=
|
||||||
|
catched` worker SHALL удалить только что добавленный торрент из qBittorrent
|
||||||
|
**вместе с его данными** (`deleteFiles = true`) по infohash загрузки. Если
|
||||||
|
повторное чтение показало `state == catched` (транзиентный сбой) либо само не
|
||||||
|
удалось, worker торрент удалять SHALL NOT — переход доводится на следующем тике
|
||||||
|
усыновлением присутствующей (нашей) раздачи. Удаление SHALL идти через API
|
||||||
|
qBittorrent (`torrents/delete`), не прямыми fs-операциями. Это легитимная уборка
|
||||||
|
**собственного** артефакта, а не пользовательских данных: инвариант «источник
|
||||||
|
неприкосновенен» защищает существующие раздачи/файлы пользователя под
|
||||||
|
`paths.downloads`, а здесь удаляется торрент, который сам worker добавил
|
||||||
|
секундами ранее — уже после намерения отмены. Состояние отменённой задачи
|
||||||
|
(`cancelled`) уборка трогать SHALL NOT; неуспех удаления SHALL логироваться
|
||||||
|
(торрент временно остаётся, повторная авто-уборка не требуется).
|
||||||
|
|
||||||
|
- **Негативный инвариант (удаляем только своё).** Удаление-с-данными допустимо
|
||||||
|
ТОЛЬКО для торрента, который worker создал именно этим `add`. Торрент, который
|
||||||
|
присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал тот же
|
||||||
|
infohash / внешний торрент с тем же хешем), удалять с данными worker SHALL NOT —
|
||||||
|
иначе снёс бы чужие данные в нарушение инварианта. Гарантию обеспечивает
|
||||||
|
подтверждение отсутствия перед `add`: путь уборки достижим только тогда, когда
|
||||||
|
отсутствие infohash было подтверждено непосредственно перед `add`; при
|
||||||
|
обнаруженном присутствии (или недоступном листинге) `add` не выполняется вовсе.
|
||||||
|
|
||||||
|
Записи об этом пути (торрент оставлен после отмены → удаляем; факт удаления) worker
|
||||||
|
SHALL логировать на уровне `WARN` с корреляцией по `download_id`/`infohash` и без
|
||||||
|
секретов; неуспех удаления — на `ERROR`.
|
||||||
|
|
||||||
|
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
|
||||||
|
ещё нет в qBittorrent
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
||||||
|
нашей категорией и `rename`
|
||||||
|
- **AND** загрузка переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
||||||
|
сохранёнными байтами файла, торрента ещё нет в qBittorrent
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
||||||
|
нашей категорией и `rename`, без обращения к magnet-хешу
|
||||||
|
- **AND** загрузка переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Апгрейд magnet → .torrent до подготовки запроса — добавляется файлом
|
||||||
|
|
||||||
|
- **GIVEN** загрузка попала в снимок списка `catched` с `source_type = magnet`
|
||||||
|
- **WHEN** апгрейд до `.torrent` применяется после снятия снимка, но до re-read
|
||||||
|
записи под блокировкой, и worker обрабатывает эту загрузку
|
||||||
|
- **THEN** worker берёт `source_type = torrent` из перечитанной записи и
|
||||||
|
добавляет раздачу сохранёнными байтами файлом (`torrents`)
|
||||||
|
- **AND** magnet-ссылка из устаревшего снимка не используется
|
||||||
|
|
||||||
|
#### Scenario: Апгрейд magnet → .torrent в окне вывода имени — добавляется magnet
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched` с `source_type = magnet`, запрос на добавление
|
||||||
|
подготовлен, worker выводит отображаемое имя вне блокировки
|
||||||
|
- **WHEN** апгрейд до `.torrent` применяется именно в это окно
|
||||||
|
- **THEN** worker добавляет magnet-ссылку из подготовленного запроса — апгрейд в
|
||||||
|
этом окне не подхватывается (известное ограничение)
|
||||||
|
- **AND** на закрытом трекере раздача зависает в `metaDL` и по `magnet_timeout`
|
||||||
|
задача уходит в `failed`
|
||||||
|
- **AND** восстановление требует ручного шага: убрать зависшую раздачу из
|
||||||
|
qBittorrent, после чего `Retry` добавляет источник заново по актуальному
|
||||||
|
`source_type` — сохранёнными байтами
|
||||||
|
|
||||||
|
#### Scenario: Байты .torrent недоступны — повтор
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched` с `source_type = torrent`, торрента в
|
||||||
|
qBittorrent нет, но сохранённые байты `.torrent` прочитать не удалось
|
||||||
|
- **WHEN** worker готовит запрос на добавление
|
||||||
|
- **THEN** worker `add` НЕ вызывает
|
||||||
|
- **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике
|
||||||
|
(отсечка — `catch_timeout`)
|
||||||
|
|
||||||
|
#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в
|
||||||
|
qBittorrent (добавлен ранее вручную/другим клиентом либо `add` прошёл на
|
||||||
|
прошлом тике, а запись перехода не удалась)
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** worker НЕ вызывает `qbt.Add` и НЕ выводит отображаемое имя через LLM
|
||||||
|
- **AND** `display_name` записывается из имени присутствующей раздачи
|
||||||
|
- **AND** загрузка переходит в `downloading` и идёт обычным путём к раскладке
|
||||||
|
|
||||||
|
#### Scenario: qBittorrent недоступен при проверке присутствия — повтор
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, листинг раздач qBittorrent не удался
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** worker НЕ вызывает namer и НЕ добавляет источник
|
||||||
|
- **AND** загрузка остаётся в `catched` и попытка повторяется на следующем тике
|
||||||
|
|
||||||
|
#### Scenario: Временный сбой добавления — повтор
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался
|
||||||
|
- **WHEN** worker пытается добавить источник и `add` возвращает ошибку
|
||||||
|
- **THEN** загрузка остаётся в `catched`
|
||||||
|
- **AND** на следующем тике попытка добавления повторяется
|
||||||
|
|
||||||
|
#### Scenario: Свежий листинг перед add недоступен — повтор
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, торрента в снимке тика нет, имя выведено
|
||||||
|
- **WHEN** свежий листинг присутствия непосредственно перед `add` не удался
|
||||||
|
(сетевой сбой)
|
||||||
|
- **THEN** worker `add` НЕ вызывает (отсутствие infohash не подтверждено)
|
||||||
|
- **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике
|
||||||
|
|
||||||
|
#### Scenario: Отмена до add — источник не добавляется
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, worker выводит отображаемое имя вне блокировки
|
||||||
|
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`) во время
|
||||||
|
вывода имени, а затем worker перечитывает состояние перед `add`
|
||||||
|
- **THEN** re-read видит, что загрузка уже не в `catched`, и `qbt.Add` НЕ
|
||||||
|
вызывается
|
||||||
|
- **AND** источник в qBittorrent не добавляется, задача остаётся `cancelled`
|
||||||
|
|
||||||
|
#### Scenario: Отмена в окне после add — добавленный торрент удаляется с данными
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, отсутствие её infohash в qBittorrent
|
||||||
|
подтверждено перед `add`, и `add` прошёл успешно
|
||||||
|
- **WHEN** отмена (`catched → cancelled`) приходит в окне между `add` и записью
|
||||||
|
перехода, из-за чего запись перехода не применяется, а re-read состояния под
|
||||||
|
блокировкой показывает `state != catched`
|
||||||
|
- **THEN** worker удаляет только что добавленный торрент из qBittorrent вместе с
|
||||||
|
его данными (`deleteFiles = true`) по infohash загрузки
|
||||||
|
- **AND** пишет `WARN` о том, что торрент оставлен после отмены и удалён
|
||||||
|
- **AND** состояние задачи остаётся `cancelled`
|
||||||
|
|
||||||
|
#### Scenario: Сбой записи перехода без отмены — торрент не удаляется
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, `add` прошёл успешно, но запись перехода
|
||||||
|
`PromoteCatched` вернула ошибку из-за транзиентного сбоя хранилища
|
||||||
|
- **WHEN** re-read состояния под блокировкой показывает, что загрузка всё ещё в
|
||||||
|
`catched` (отмены не было)
|
||||||
|
- **THEN** worker торрент из qBittorrent НЕ удаляет (это наш живой торрент)
|
||||||
|
- **AND** переход доводится на следующем тике усыновлением присутствующей раздачи
|
||||||
|
|
||||||
|
#### Scenario: Пред-существующий торрент не удаляется с данными
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, чей infohash уже присутствует в qBittorrent к
|
||||||
|
моменту проверки перед `add` (внешний торрент/раздача пользователя с тем же
|
||||||
|
хешем)
|
||||||
|
- **WHEN** worker обрабатывает тик и параллельно приходит отмена
|
||||||
|
- **THEN** worker `add` НЕ выполняет и торрент с данными НЕ удаляет (чужие данные
|
||||||
|
неприкосновенны)
|
||||||
|
- **AND** загрузка пропускается в этот тик (усыновление присутствующей раздачи —
|
||||||
|
на следующем тике, если задача ещё активна)
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### 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) — **при условии, что апгрейд лёг до того, как воркер подготовил запрос
|
||||||
|
на добавление**. Апгрейд, попавший в узкое окно вывода отображаемого имени,
|
||||||
|
воркер уже не подхватит и добавит magnet: это известное ограничение, названное в
|
||||||
|
`download-tracking` «Добавление пойманной загрузки в qBittorrent» вместе с
|
||||||
|
маршрутом восстановления. Апгрейд 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), если апгрейд лёг до
|
||||||
|
подготовки запроса на добавление — см. `download-tracking` «Добавление
|
||||||
|
пойманной загрузки в qBittorrent»
|
||||||
|
|
||||||
|
#### Scenario: Апгрейд в окне вывода имени — воркер добавит magnet
|
||||||
|
|
||||||
|
- **GIVEN** активная загрузка в `catched` с `source_type = magnet`, для которой
|
||||||
|
воркер уже подготовил запрос на добавление и выводит отображаемое имя
|
||||||
|
- **WHEN** принимается `.torrent` с тем же инфохэшем
|
||||||
|
- **THEN** апгрейд применяется (байты сохранены, `source_type` стал `torrent`)
|
||||||
|
- **AND** гарантии, что воркер добавит раздачу файлом, приём в этом случае не
|
||||||
|
даёт: исход добавления определяет `download-tracking` «Добавление пойманной
|
||||||
|
загрузки в qBittorrent», где это окно и его цена описаны
|
||||||
|
|
||||||
|
#### 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,65 @@
|
|||||||
|
## 1. Дельта-спека
|
||||||
|
|
||||||
|
- [x] 1.1 Переформулировать требование о re-read `source_type` в дельта-спеке
|
||||||
|
`download-tracking`: «под блокировкой переходов после тик-снимка, перед
|
||||||
|
подготовкой запроса на добавление», с сохранённой рациональю «не
|
||||||
|
полагаться на снимок, снятый вне блокировки»
|
||||||
|
- [x] 1.2 Назвать остаточное окно (апгрейд в момент вывода имени) явно:
|
||||||
|
известное ограничение, запрос под блокировкой не пересобирается, и почему
|
||||||
|
(`sourceAddParts` читает байты `.torrent`, медленное под замком не держим)
|
||||||
|
- [x] 1.3 Записать достоверный маршрут восстановления: `metaDL` →
|
||||||
|
`magnet_timeout` → `failed`; сверка не оживляет, `Retry` при живом
|
||||||
|
торренте перецепляется; нужен ручной шаг — убрать зависшую раздачу из
|
||||||
|
qBittorrent, после чего `Retry` добавит байтами
|
||||||
|
- [x] 1.4 Уточнить пункт «Re-read состояния перед `add`»: он проверяет только
|
||||||
|
состояние и запрос не пересобирает
|
||||||
|
- [x] 1.5 Добавить сценарий «Апгрейд magnet → .torrent до подготовки запроса —
|
||||||
|
добавляется файлом» (закрытое окно, оракул — существующий тест)
|
||||||
|
- [x] 1.6 Добавить сценарий «Апгрейд magnet → .torrent в окне вывода имени —
|
||||||
|
добавляется magnet» с исходом `magnet_timeout` → `failed` → ручной шаг +
|
||||||
|
`Retry`
|
||||||
|
|
||||||
|
## 2. Отработка ревью предложения (профиль `design`, проход `specs`)
|
||||||
|
|
||||||
|
- [x] 2.1 (major) Дельта на `ingest`: обусловить обещание «воркер добавит
|
||||||
|
раздачу файлом» моментом апгрейда и добавить сценарий на окно — иначе
|
||||||
|
ложная гарантия переезжает в соседнюю capability (design D5)
|
||||||
|
- [x] 2.2 (minor) Снять `SHALL NOT`/`SHALL` там, где решение отложено:
|
||||||
|
«пересобирать запрос под блокировкой» и «восстановление требует ручного
|
||||||
|
шага» — в описательный залог (design D6)
|
||||||
|
- [x] 2.3 (minor) Поправить «снимок снят вне блокировки» → список снят под
|
||||||
|
блокировкой, но к моменту обработки устарел (`worker.go:392-394`)
|
||||||
|
- [x] 2.4 (minor) Назвать исход ветки «байты `.torrent` недоступны»:
|
||||||
|
предложение в требовании плюс сценарий (design D7)
|
||||||
|
- [x] 2.5 (границы спеки) Назвать, что на публичном трекере/DHT ущерба нет, и
|
||||||
|
что повторный приём `.torrent` без удаления зависшей раздачи усыновит её
|
||||||
|
же
|
||||||
|
- [x] 2.6 Поправить `design.md`: `Delete` из `failed` недоступен
|
||||||
|
(`state-reconciliation` «Полное удаление загрузки пользователем»)
|
||||||
|
|
||||||
|
## 3. Проверка
|
||||||
|
|
||||||
|
- [x] 3.1 `openspec validate --strict catched-source-type-reread-wording` —
|
||||||
|
зелёный
|
||||||
|
- [x] 3.2 `git diff --stat` не содержит ни одного файла под `internal/`
|
||||||
|
- [x] 3.3 `task gate` — зелёный; `TestProcessCatchedReReadsSourceTypeUnderLock`
|
||||||
|
проходит без правок теста
|
||||||
|
|
||||||
|
## 4. Критерии приёмки (из постановки задачи)
|
||||||
|
|
||||||
|
- [x] 4.1 Требование спеки описывает фактическое поведение: re-read
|
||||||
|
`source_type` под блокировкой после тик-снимка, апгрейд в окне namer'а
|
||||||
|
назван допустимым и самоисцеляемым (оракул: `openspec validate --strict`)
|
||||||
|
- [x] 4.2 В спеке есть сценарий, покрывающий апгрейд в окне namer'а с исходом
|
||||||
|
«magnet из снимка, дальше `magnet_timeout` → `failed` → `Retry`» (оракул:
|
||||||
|
тот же прогон плюс существующий
|
||||||
|
`TestProcessCatchedReReadsSourceTypeUnderLock` проходит без правок)
|
||||||
|
- [x] 4.3 Ни один файл под `internal/` в диффе не изменён (оракул:
|
||||||
|
`git diff --stat` в отчёте ревью)
|
||||||
|
|
||||||
|
**Отклонение от буквы критерия 4.1/4.2, обнаруженное при сверке с кодом:**
|
||||||
|
маршрут «`Retry` перечитает актуальный `source_type` и добьёт» верен НЕ всегда —
|
||||||
|
`Retry` при живом торренте в `metaDL` перецепляется к нему без повторного `add`
|
||||||
|
(`internal/worker/worker.go:1043-1059`, `classify` на `:1130-1142`). В спеку
|
||||||
|
пишется достоверная версия с ручным шагом; см. `design.md` D2. Слово
|
||||||
|
«самоисцеляемое» заменено на «известное ограничение с названной ценой».
|
||||||
@@ -137,10 +137,42 @@ Conflict`), и загрузка зациклилась бы на ретраях.
|
|||||||
magnet-хешу вместо файла система SHALL NOT.
|
magnet-хешу вместо файла система SHALL NOT.
|
||||||
|
|
||||||
`source_type` для выбора способа добавления worker SHALL перечитывать **под
|
`source_type` для выбора способа добавления worker SHALL перечитывать **под
|
||||||
блокировкой переходов** непосредственно перед добавлением (а не полагаться на
|
блокировкой переходов после тик-снимка** — перед подготовкой запроса на
|
||||||
снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом
|
добавление, а не полагаясь на снимок списка `catched`, который к моменту
|
||||||
пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из
|
обработки загрузки уже устарел (блокировка между снятием списка и обработкой
|
||||||
устаревшего снимка, хотя БД уже `torrent`.
|
отпускается). Иначе апгрейд пойманной magnet-задачи до `.torrent` (см.
|
||||||
|
`ingest`), легший между снятием списка и обработкой, был бы пропущен, и worker
|
||||||
|
добавил бы magnet из устаревшего снимка, хотя в БД уже `torrent`.
|
||||||
|
|
||||||
|
Если подготовка запроса не удалась (сохранённые байты `.torrent` недоступны),
|
||||||
|
worker `add` выполнять SHALL NOT и SHALL оставлять загрузку в `catched` для
|
||||||
|
повтора на следующем тике; отсечка — `catch_timeout`.
|
||||||
|
|
||||||
|
Между этим re-read и самим `add` остаётся окно: вывод отображаемого имени идёт
|
||||||
|
секунды вне блокировки (см. ниже), а запрос на добавление собран до него и по
|
||||||
|
свежей записи не пересобирается. Апгрейд magnet → `.torrent`, легший ровно в это
|
||||||
|
окно, worker не подхватывает и добавляет magnet-ссылку. Это **известное
|
||||||
|
ограничение с названной ценой**, а не гарантия: сегодня запрос собран до вывода
|
||||||
|
имени и по свежей записи не пересобирается. Закрывать ли окно кодом и каким
|
||||||
|
способом (пересборкой запроса или сравнением одного `source_type` под замком с
|
||||||
|
пропуском тика при расхождении) — открытый вопрос за границами этого
|
||||||
|
требования.
|
||||||
|
|
||||||
|
Ущерб окна ограничен, но не бесплатен, и спека называет его точно. На публичном
|
||||||
|
трекере (или при живом DHT) magnet доберёт метаданные и задача пойдёт штатно —
|
||||||
|
ущерба нет вовсе. На закрытом трекере, ради которого `.torrent` и подавался,
|
||||||
|
magnet без метаданных зависает в `metaDL`, и предохранитель `magnet_timeout`
|
||||||
|
уводит задачу в `failed` (см. «Таймауты-предохранители downloading»). Сама собой
|
||||||
|
задача из этого состояния не восстанавливается: фоновая сверка оживляет только
|
||||||
|
продвинувшийся источник, а `Retry` при живом и здоровом торренте (`metaDL`
|
||||||
|
считается живым) перецепляется к нему и повторный `add` по актуальному
|
||||||
|
`source_type` не делает — см. `state-reconciliation` «Ручной повтор
|
||||||
|
зависшей/упавшей загрузки из транспортов». Восстановление сегодня требует
|
||||||
|
ручного шага: убрать
|
||||||
|
зависшую magnet-раздачу из qBittorrent, после чего `Retry` добавит источник
|
||||||
|
заново по актуальному `source_type` — сохранёнными байтами, которые апгрейд уже
|
||||||
|
записал. Без этого шага повторный приём `.torrent` не помогает: новая загрузка
|
||||||
|
усыновит ту же зависшую раздачу (см. «усыновить» выше).
|
||||||
|
|
||||||
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
|
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
|
||||||
загрузку в `catched` для повторной попытки на следующем тике; переход в
|
загрузку в `catched` для повторной попытки на следующем тике; переход в
|
||||||
@@ -167,7 +199,9 @@ re-read состояния → `[вне блокировки]` свежий ли
|
|||||||
вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если
|
вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если
|
||||||
она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик
|
она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик
|
||||||
пропустить. Это сужает окно гонки до промежутка между re-read и записью
|
пропустить. Это сужает окно гонки до промежутка между re-read и записью
|
||||||
перехода.
|
перехода. Этот re-read проверяет **только состояние** и запрос на добавление не
|
||||||
|
пересобирает — за `source_type` отвечает более ранний re-read после
|
||||||
|
тик-снимка (см. выше).
|
||||||
|
|
||||||
- **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add`
|
- **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add`
|
||||||
worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с
|
worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с
|
||||||
@@ -232,6 +266,37 @@ SHALL логировать на уровне `WARN` с корреляцией п
|
|||||||
нашей категорией и `rename`, без обращения к magnet-хешу
|
нашей категорией и `rename`, без обращения к magnet-хешу
|
||||||
- **AND** загрузка переходит в `downloading`
|
- **AND** загрузка переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Апгрейд magnet → .torrent до подготовки запроса — добавляется файлом
|
||||||
|
|
||||||
|
- **GIVEN** загрузка попала в снимок списка `catched` с `source_type = magnet`
|
||||||
|
- **WHEN** апгрейд до `.torrent` применяется после снятия снимка, но до re-read
|
||||||
|
записи под блокировкой, и worker обрабатывает эту загрузку
|
||||||
|
- **THEN** worker берёт `source_type = torrent` из перечитанной записи и
|
||||||
|
добавляет раздачу сохранёнными байтами файлом (`torrents`)
|
||||||
|
- **AND** magnet-ссылка из устаревшего снимка не используется
|
||||||
|
|
||||||
|
#### Scenario: Апгрейд magnet → .torrent в окне вывода имени — добавляется magnet
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched` с `source_type = magnet`, запрос на добавление
|
||||||
|
подготовлен, worker выводит отображаемое имя вне блокировки
|
||||||
|
- **WHEN** апгрейд до `.torrent` применяется именно в это окно
|
||||||
|
- **THEN** worker добавляет magnet-ссылку из подготовленного запроса — апгрейд в
|
||||||
|
этом окне не подхватывается (известное ограничение)
|
||||||
|
- **AND** на закрытом трекере раздача зависает в `metaDL` и по `magnet_timeout`
|
||||||
|
задача уходит в `failed`
|
||||||
|
- **AND** восстановление требует ручного шага: убрать зависшую раздачу из
|
||||||
|
qBittorrent, после чего `Retry` добавляет источник заново по актуальному
|
||||||
|
`source_type` — сохранёнными байтами
|
||||||
|
|
||||||
|
#### Scenario: Байты .torrent недоступны — повтор
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched` с `source_type = torrent`, торрента в
|
||||||
|
qBittorrent нет, но сохранённые байты `.torrent` прочитать не удалось
|
||||||
|
- **WHEN** worker готовит запрос на добавление
|
||||||
|
- **THEN** worker `add` НЕ вызывает
|
||||||
|
- **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике
|
||||||
|
(отсечка — `catch_timeout`)
|
||||||
|
|
||||||
#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
|
#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
|
||||||
|
|
||||||
- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в
|
- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в
|
||||||
|
|||||||
@@ -511,11 +511,15 @@ v2-хеш (для v2/гибридного файла, BEP52) SHALL извлек
|
|||||||
привязав их к этой загрузке, и сменить её `source_type` на `torrent`. Тем
|
привязав их к этой загрузке, и сменить её `source_type` на `torrent`. Тем
|
||||||
самым воркер добавит раздачу файлом, а не magnet-хешем (иначе на закрытом
|
самым воркер добавит раздачу файлом, а не magnet-хешем (иначе на закрытом
|
||||||
трекере без DHT метаданные не докачаются, а magnet застрянет в metaDL →
|
трекере без DHT метаданные не докачаются, а magnet застрянет в metaDL →
|
||||||
failed). Апгрейд SHALL применяться ТОЛЬКО пока загрузка в `catched` (воркер
|
failed) — **при условии, что апгрейд лёг до того, как воркер подготовил запрос
|
||||||
источник ещё не добавил); для уже добавленной (`downloading` и далее)
|
на добавление**. Апгрейд, попавший в узкое окно вывода отображаемого имени,
|
||||||
загрузки смена `source_type` при дедупе выполняться SHALL NOT — её судьба
|
воркер уже не подхватит и добавит magnet: это известное ограничение, названное в
|
||||||
решается путями retry/сверки, а не приёмом. Апгрейд SHALL быть best-effort:
|
`download-tracking` «Добавление пойманной загрузки в qBittorrent» вместе с
|
||||||
его неуспех приём не прерывает.
|
маршрутом восстановления. Апгрейд SHALL применяться ТОЛЬКО пока загрузка в
|
||||||
|
`catched` (воркер источник ещё не добавил); для уже добавленной (`downloading`
|
||||||
|
и далее) загрузки смена `source_type` при дедупе выполняться SHALL NOT — её
|
||||||
|
судьба решается путями retry/сверки, а не приёмом. Апгрейд SHALL быть
|
||||||
|
best-effort: его неуспех приём не прерывает.
|
||||||
|
|
||||||
Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя
|
Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя
|
||||||
раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий)
|
раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий)
|
||||||
@@ -567,7 +571,19 @@ NOT.
|
|||||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||||
- **AND** байты `.torrent` сохраняются привязанными к ней, а её `source_type`
|
- **AND** байты `.torrent` сохраняются привязанными к ней, а её `source_type`
|
||||||
становится `torrent` — в одной транзакции
|
становится `torrent` — в одной транзакции
|
||||||
- **AND** воркер добавит раздачу файлом (не по magnet)
|
- **AND** воркер добавит раздачу файлом (не по magnet), если апгрейд лёг до
|
||||||
|
подготовки запроса на добавление — см. `download-tracking` «Добавление
|
||||||
|
пойманной загрузки в qBittorrent»
|
||||||
|
|
||||||
|
#### Scenario: Апгрейд в окне вывода имени — воркер добавит magnet
|
||||||
|
|
||||||
|
- **GIVEN** активная загрузка в `catched` с `source_type = magnet`, для которой
|
||||||
|
воркер уже подготовил запрос на добавление и выводит отображаемое имя
|
||||||
|
- **WHEN** принимается `.torrent` с тем же инфохэшем
|
||||||
|
- **THEN** апгрейд применяется (байты сохранены, `source_type` стал `torrent`)
|
||||||
|
- **AND** гарантии, что воркер добавит раздачу файлом, приём в этом случае не
|
||||||
|
даёт: исход добавления определяет `download-tracking` «Добавление пойманной
|
||||||
|
загрузки в qBittorrent», где это окно и его цена описаны
|
||||||
|
|
||||||
#### Scenario: Magnet-задача уже добавлена — апгрейда нет
|
#### Scenario: Magnet-задача уже добавлена — апгрейда нет
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user