download-tracking: требование о re-read source_type приведено к коду

- re-read `source_type` перечитывается под блокировкой после тик-снимка, а
  остаточное окно вывода имени названо известным ограничением с ценой и
  достоверным маршрутом восстановления (ручной шаг + `Retry`, не самоисцеление)
- в `ingest` снята парная ложная гарантия «воркер добавит раздачу файлом»,
  добавлены сценарии на оба окна апгрейда и на недоступные байты `.torrent`
- заведён ADR о том, что при разрыве спека↔код двигается тот, чья формулировка
  сильнее рационали
This commit is contained in:
av
2026-08-06 14:44:21 +03:00
parent f42db0a275
commit 30ee598547
11 changed files with 1179 additions and 11 deletions
@@ -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; правок нет.
- Кода, схемы БД, конфига и внешних контрактов изменение не касается.
@@ -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 пункта на выходе
(слияние по причине, не отсев); молчком не выброшено ничего.
@@ -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** загрузка пропускается в этот тик (усыновление присутствующей раздачи —
на следующем тике, если задача ещё активна)
@@ -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. Слово
«самоисцеляемое» заменено на «известное ограничение с названной ценой».
+70 -5
View File
@@ -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`, торрент которой уже присутствует в
+22 -6
View File
@@ -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-задача уже добавлена — апгрейда нет