From 30ee5985472cdb68e393d7b3877ce7ec78117372 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Thu, 6 Aug 2026 14:44:21 +0300 Subject: [PATCH] =?UTF-8?q?download-tracking:=20=D1=82=D1=80=D0=B5=D0=B1?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BE=20re-read=20sourc?= =?UTF-8?q?e=5Ftype=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BA=20=D0=BA=D0=BE=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - re-read `source_type` перечитывается под блокировкой после тик-снимка, а остаточное окно вывода имени названо известным ограничением с ценой и достоверным маршрутом восстановления (ручной шаг + `Retry`, не самоисцеление) - в `ingest` снята парная ложная гарантия «воркер добавит раздачу файлом», добавлены сценарии на оба окна апгрейда и на недоступные байты `.torrent` - заведён ADR о том, что при разрыве спека↔код двигается тот, чья формулировка сильнее рационали --- ...8-06-spec-follows-code-on-narrow-window.md | 80 +++++ docs/adr/README.md | 1 + .../.openspec.yaml | 2 + .../design.md | 180 +++++++++++ .../proposal.md | 72 +++++ .../review/triage.md | 276 +++++++++++++++++ .../specs/download-tracking/spec.md | 279 ++++++++++++++++++ .../specs/ingest/spec.md | 132 +++++++++ .../tasks.md | 65 ++++ openspec/specs/download-tracking/spec.md | 75 ++++- openspec/specs/ingest/spec.md | 28 +- 11 files changed, 1179 insertions(+), 11 deletions(-) create mode 100644 docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md create mode 100644 openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/design.md create mode 100644 openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/proposal.md create mode 100644 openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/review/triage.md create mode 100644 openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/specs/download-tracking/spec.md create mode 100644 openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/specs/ingest/spec.md create mode 100644 openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/tasks.md diff --git a/docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md b/docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md new file mode 100644 index 0000000..4be346c --- /dev/null +++ b/docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md @@ -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'а +> случится в эксплуатации хотя бы раз — признак в логах — либо когда стоимость +> ручного шага станет заметной. До того — принято и описано. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2de2851..26062c0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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-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) | — | diff --git a/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/.openspec.yaml b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/.openspec.yaml new file mode 100644 index 0000000..84cfc12 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-06 diff --git a/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/design.md b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/design.md new file mode 100644 index 0000000..6d0ac8b --- /dev/null +++ b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/design.md @@ -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/**` в диффе и то, что + существующий тест продолжает проходить без правок. diff --git a/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/proposal.md b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/proposal.md new file mode 100644 index 0000000..591aea5 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/proposal.md @@ -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; правок нет. +- Кода, схемы БД, конфига и внешних контрактов изменение не касается. diff --git a/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/review/triage.md b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/review/triage.md new file mode 100644 index 0000000..b379e27 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/review/triage.md @@ -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: проверка «каждая цитата + ` «Заголовок»` существует дословно в + `openspec/specs//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 пункта на выходе +(слияние по причине, не отсев); молчком не выброшено ничего. diff --git a/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/specs/download-tracking/spec.md b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/specs/download-tracking/spec.md new file mode 100644 index 0000000..869059e --- /dev/null +++ b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/specs/download-tracking/spec.md @@ -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** загрузка пропускается в этот тик (усыновление присутствующей раздачи — + на следующем тике, если задача ещё активна) diff --git a/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/specs/ingest/spec.md b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/specs/ingest/spec.md new file mode 100644 index 0000000..cea0727 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/specs/ingest/spec.md @@ -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** приём отклоняется с ошибкой, загрузка не создаётся diff --git a/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/tasks.md b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/tasks.md new file mode 100644 index 0000000..6c19b7d --- /dev/null +++ b/openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/tasks.md @@ -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. Слово +«самоисцеляемое» заменено на «известное ограничение с названной ценой». diff --git a/openspec/specs/download-tracking/spec.md b/openspec/specs/download-tracking/spec.md index e18a6b8..2f4e548 100644 --- a/openspec/specs/download-tracking/spec.md +++ b/openspec/specs/download-tracking/spec.md @@ -137,10 +137,42 @@ Conflict`), и загрузка зациклилась бы на ретраях. magnet-хешу вместо файла система SHALL NOT. `source_type` для выбора способа добавления worker SHALL перечитывать **под -блокировкой переходов** непосредственно перед добавлением (а не полагаться на -снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом -пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из -устаревшего снимка, хотя БД уже `torrent`. +блокировкой переходов после тик-снимка** — перед подготовкой запроса на +добавление, а не полагаясь на снимок списка `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` для повторной попытки на следующем тике; переход в @@ -167,7 +199,9 @@ re-read состояния → `[вне блокировки]` свежий ли вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик пропустить. Это сужает окно гонки до промежутка между re-read и записью - перехода. + перехода. Этот re-read проверяет **только состояние** и запрос на добавление не + пересобирает — за `source_type` отвечает более ранний re-read после + тик-снимка (см. выше). - **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add` worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с @@ -232,6 +266,37 @@ SHALL логировать на уровне `WARN` с корреляцией п нашей категорией и `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`, торрент которой уже присутствует в diff --git a/openspec/specs/ingest/spec.md b/openspec/specs/ingest/spec.md index b9957ba..04c326d 100644 --- a/openspec/specs/ingest/spec.md +++ b/openspec/specs/ingest/spec.md @@ -511,11 +511,15 @@ v2-хеш (для v2/гибридного файла, BEP52) SHALL извлек привязав их к этой загрузке, и сменить её `source_type` на `torrent`. Тем самым воркер добавит раздачу файлом, а не magnet-хешем (иначе на закрытом трекере без DHT метаданные не докачаются, а magnet застрянет в metaDL → -failed). Апгрейд SHALL применяться ТОЛЬКО пока загрузка в `catched` (воркер -источник ещё не добавил); для уже добавленной (`downloading` и далее) -загрузки смена `source_type` при дедупе выполняться SHALL NOT — её судьба -решается путями retry/сверки, а не приёмом. Апгрейд SHALL быть best-effort: -его неуспех приём не прерывает. +failed) — **при условии, что апгрейд лёг до того, как воркер подготовил запрос +на добавление**. Апгрейд, попавший в узкое окно вывода отображаемого имени, +воркер уже не подхватит и добавит magnet: это известное ограничение, названное в +`download-tracking` «Добавление пойманной загрузки в qBittorrent» вместе с +маршрутом восстановления. Апгрейд SHALL применяться ТОЛЬКО пока загрузка в +`catched` (воркер источник ещё не добавил); для уже добавленной (`downloading` +и далее) загрузки смена `source_type` при дедупе выполняться SHALL NOT — её +судьба решается путями retry/сверки, а не приёмом. Апгрейд SHALL быть +best-effort: его неуспех приём не прерывает. Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий) @@ -567,7 +571,19 @@ NOT. - **THEN** новая загрузка не создаётся, возвращается существующая - **AND** байты `.torrent` сохраняются привязанными к ней, а её `source_type` становится `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-задача уже добавлена — апгрейда нет