From 0b02a8c22467dc3fd6f47fa0db2f6067fd3640b5 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Thu, 6 Aug 2026 15:50:41 +0300 Subject: [PATCH] =?UTF-8?q?specs:=20=D0=B2=20state-reconciliation=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D1=8B=20Cancel=20?= =?UTF-8?q?=D0=B8=20Dismiss=20=D0=BF=D0=BE=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - требование «Ручное закрытие загрузки» приведено к коду: два пути закрытия, error_code на каждом, раскладка поверхностей — описательно, а не SHALL - уборка своего торрента после отмены названа исключением по состоянию, а не по команде; убрана ложная гарантия «данных пользователя не касается» - в docs/review.md записан проскочивший дефект гарда окна после add и новый вопрос проходу adversary про асимметрию признака владения --- docs/review.md | 41 +- .../.openspec.yaml | 2 + .../design.md | 284 ++++++++++++++ .../proposal.md | 68 ++++ .../review/triage.md | 367 ++++++++++++++++++ .../specs/state-reconciliation/spec.md | 170 ++++++++ .../tasks.md | 64 +++ openspec/specs/state-reconciliation/spec.md | 157 ++++++-- 8 files changed, 1125 insertions(+), 28 deletions(-) create mode 100644 openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/design.md create mode 100644 openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/proposal.md create mode 100644 openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/review/triage.md create mode 100644 openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/specs/state-reconciliation/spec.md create mode 100644 openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/tasks.md diff --git a/docs/review.md b/docs/review.md index b93477c..79f18b2 100644 --- a/docs/review.md +++ b/docs/review.md @@ -109,6 +109,10 @@ Go-сервиса и что здесь уже проскакивало. Устр - `adversary`: что даёт крафт-магнет с чужим или подставным инфохэшем — присоединение к чужой активной загрузке, отравление владения? (открытая задача про идентичность инфохэшей) +- `adversary`: где признак «это наше» снимается с одной сущности, а действие + применяется к другой — присутствие раздачи в qBittorrent против байтов на + диске, запись в БД против файла, инфохэш против содержимого? (журнал, + 2026-08-06: уборка своего торрента сносила чужие файлы) - `ops`: что делает эта ветка, когда qBittorrent недоступен несколько минут подряд — сколько ERROR-строк в секунду и меняется ли состояние задач? (задача про ERROR-шторм фоновых циклов) @@ -205,7 +209,42 @@ merge-раскладка при повторном добавлении разд ### Записи -Пока пусто. Журнал заведён 2026-07-23 вместе с переработкой конвейера +Журнал заведён 2026-07-23 вместе с переработкой конвейера ([ADR-2026-07-23-review-pipeline-generative](adr/ADR-2026-07-23-review-pipeline-generative.md)); случаи до этой даты не восстанавливались — восстановленная постфактум причина непоймания недостоверна, а именно она и нужна. + +## 2026-08-06 — уборка своего торрента после отмены сносит чужие файлы [проскочил] + +- **Где:** `internal/worker/worker.go:501-556` — гард `:501-509`, удаление + `:550`. Норма — `openspec/specs/download-tracking/spec.md`, требование + «Добавление пойманной загрузки в qBittorrent». +- **Симптом:** найден проходом `adversary` на ревью задачи + `dismiss-marker-lost` (2026-08-06), не в эксплуатации. В проде не всплывал. +- **Причина:** гард «подтверждённое отсутствие непосредственно перед `add`» + подтверждает отсутствие **записи торрента** в qBittorrent, но не отсутствие + **данных** на диске. Пользователь, снявший раздачу из qBittorrent с + сохранением файлов (`download-tracking` сама предписывает это как способ + восстановления зависшей magnet-раздачи), и подавший тот же торрент заново, + получает `add`, подхватывающий пред-существующие файлы. Отмена в окне между + re-read и `PromoteCatched` даёт `torrents/delete` с `deleteFiles=true` по + этим файлам. Исключение инварианта «источник неприкосновенен» покрывает + «собственный торрент», а признак «своё» подменён на «торрента не было». +- **Чем воспроизведён:** тестом на фейковом клиенте qBittorrent во временном + каталоге прогона (`tmp/`, не сохранён): `Cancel` в окне после `add` вызывает + `Delete(hashes, deleteFiles=true)` при живом файле под `paths.downloads`, + созданном до `add`. Тот же путь достижим через `Dismiss` — гейт `Dismiss` + шире, а уборка срабатывает по состоянию (`after.State != catched`), а не по + команде. **Не прогонялся** последний шаг — что боевой qBittorrent по + `deleteFiles=true` физически сносит пред-существующий контент: в бой ходить + запрещено, отсюда `Confidence: medium`. +- **Почему не поймали:** окно после `add` разбиралось как **гонка** (кто + успел — отмена или промоушен) и проверялось на «не удалим ли чужой торрент». + Вопрос «а если торрента нет, но данные есть» не задавал никто: ни один + проход не спрашивал про **асимметрию признака владения** — признак снимается + с одной сущности (запись в qBittorrent), а действие применяется к другой + (байты на диске). Враждебный проход до этой задачи на данном коде не гонялся. +- **Что меняем:** вопрос `adversary` в разделе выше дополнен пунктом про + асимметрию признака владения. Сам дефект — задачей в беклоге, кандидат + `critical`; спека `state-reconciliation` в том же изменении перестала + утверждать, что уборка «данных пользователя не касается». diff --git a/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/.openspec.yaml b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/.openspec.yaml new file mode 100644 index 0000000..84cfc12 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-06 diff --git a/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/design.md b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/design.md new file mode 100644 index 0000000..6e87bee --- /dev/null +++ b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/design.md @@ -0,0 +1,284 @@ +## Context + +Аудит capability `state-reconciliation` (сверка код↔спека) нашёл расхождение в +требовании «Ручное закрытие загрузки (стоп-кран)». Спека утверждает единый путь +закрытия из любого состояния кроме `deleted`, помечаемый +`error_code = "user_dismiss"`. Код даёт **два** пути, разведённые по состояниям. + +Что показало чтение кода (провенанс каждого факта — путь:строка): + +| Факт | Где | +| --- | --- | +| `Cancel` отклоняет терминальные состояния, ставит пустые `error_code`/`error_msg` | `internal/worker/worker.go:973` | +| `Dismiss` доступен из любого состояния кроме `deleted`, ставит `user_dismiss`, из `cancelled` — no-op без записи | `internal/worker/worker.go:1001` | +| Веб-UI: «Закрыть» только для терминальных, кроме `deleted`/`cancelled` | `internal/httpapi/download.go:129` | +| Веб-UI: «Отменить» только для не-терминальных | `web/templates/partials/download_main.html:26`, `partials/card.html:32` | +| Экран ревью: «Отклонить» → `Cancel` | `web/templates/partials/review_main.html:77` | +| Telegram: «Закрыть» на `failed`/`stuck` и на `done`/`orphaned`/`target_missing` | `internal/tgbot/render.go:271`, `:290` | +| Telegram: «Отклонить» → `Cancel` на карточке ревью | `internal/tgbot/render.go:119`, `bot.go:325` | +| Telegram: `downloading`/`recognizing`/`linking`/`completed`/`catched` — карточка без действий (только ссылка в веб) | `internal/tgbot/render.go:39` | +| REST: есть `cancel` и `retry`, `dismiss` нет | `internal/httpapi/httpapi.go:152` | +| `failed` терминален, `stuck` — нет | `internal/store/download.go:54` | +| Граф переходов уже описывает пару `Cancel`/`Dismiss` прозой | `internal/store/download.go:86` | + +Важное следствие таблицы: постановка задачи описывала разрыв как +«веб-UI против домена», а он шире — раскладка путей **разная у разных +поверхностей**. Telegram даёт `Dismiss` из `stuck`, то есть из не-терминального +состояния, а на `review` даёт `Cancel`; веб-UI режет строго по терминальности. + +Ограничение по постановке: **кода изменение не трогает** — критерий приёмки +задачи требует пустого дифа под `internal/` и `web/`. + +## Goals / Non-Goals + +**Goals:** + +- Нормативный дом поведения перестаёт утверждать то, чего поверхности не дают. +- Требование называет `error_code` на каждом пути закрытия, а не на одном. +- Требование называет гарантию, которая действительно держится: закрытие + доступно из любого не-`deleted` состояния хотя бы одной поверхностью. +- Разрыв наблюдаемости назван с ценой, чтобы следующий аудит не открывал его + заново. + +**Non-Goals:** + +- Не менять поведение: `Dismiss` из не-терминальных состояний веб-UI не + зовётся, гейт `Dismissable` остаётся как есть. +- Не заводить второй ADR о приёме «спека следует за кодом» — приём уже описан + в [ADR-2026-08-06-spec-follows-code-on-narrow-window](../../../docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md). +- Не переписывать требование `review` о команде «Отклонить»: его предмет — + экран ревью, а не закрытие как класс. +- Не заводить `dismiss` в REST: REST помечен опциональным транспортом. + +## Decisions + +### D1. Спека двигается к коду — но только в той половине, где приём ADR применим + +Приём и его границы заданы ADR-2026-08-06: двигается тот, **чья формулировка +сильнее рационали**. Требование содержит **два** обещания, и приём применим +только к одному из них — ревью предложения показало, что первая редакция этого +не различала. + +- **Обещание доступности** («закрыть можно любую загрузку кроме `deleted`»). + Рациональ кодом выполнен: страница загрузки веб-UI даёт путь закрытия из + каждого не-`deleted`, не-`cancelled` состояния. Формулировка «команда + `dismiss` из любого состояния во всех транспортах» была сильнее рационали и + требовала от каждой поверхности **обе** кнопки. Здесь спека и правда + двигается к коду. +- **Обещание различимости** («закрытие помечено `user_dismiss`»). Рациональ — + различать инициатора в диагностике — кодом **не** достигнут, а по правилу + того же ADR недостающая гарантия чинится кодом. Поэтому спека этот пункт + **не закрепляет**: сегодняшняя пустота кода на пути `Cancel` описана как + факт, а не как `SHALL`, и вопрос оставлен открытым (D7). + +Альтернатива C (оставить всё как есть) отвергается по тому же основанию, что в +ADR: расхождение воспроизводится каждым аудитом, а читатель спеки считает +маркер гарантированным. + +### D2. Требование разводит доменную команду и обязательства поверхностей + +Прежний текст смешивал два уровня в одном предложении («команда доступна из +любого состояния во всех транспортах»). Новый текст разводит: нормативны +**гарантия закрытия** (страница загрузки веб-UI даёт путь из каждого +не-`deleted`/`cancelled` состояния) и **гейты доменных путей** (`Cancel` — +не-терминальные, `Dismiss` — всё кроме `deleted`). Раскладка кнопок по +поверхностям нормативной **не объявляется**: она описана как сегодняшнее +состояние, перечислением состояний, а не оборотом «там, где карточка вообще +даёт действия». + +Первая редакция закрепляла раскладку через `SHALL`, и ревью предложения нашло +в этом две беды разом: она противоречила заявке этого же решения («спека +фиксирует гарантию, не вёрстку»), а Telegram-половина формулировки была +круговой — истинной при любом поведении кода, включая удаление кнопки. +Описательная форма с перечнем состояний фальсифицируема (по ней строится +таблица «состояние × поверхность») и не превращает будущее выравнивание +поверхностей в нарушение спеки. + +Альтернатива — перечислить в спеке точную раскладку клавиатур Telegram — +отклонена: спека стала бы копией `render.go` и краснела бы от косметической +правки кнопки. + +### D3. `error_code` называется на каждом пути; нормативен только `user_dismiss` + +Пустой код — тоже наблюдаемое обязательство, и умолчание о нём породило задачу. +Пишем прямо: `Dismiss` → `"user_dismiss"` (нормативно), `Cancel` → сегодня +пусто (описательно, см. D1 и D7). К этому добавлено то, чего в старом тексте не +было вовсе: **оба** пути замещают прежнюю диагностику записи. Первая редакция +приписывала затирание только `Cancel` и тем создавала ложное впечатление, что +маркерный путь причину сохраняет; `worker.go:1016` пишет `user_dismiss` поверх +`qbit_error` ровно так же. + +### D4. Ограничение названо в теле требования, а не спрятано в design + +Форма взята из ADR-2026-08-06: молчащая ложная гарантия дороже названного +ограничения. Абзац «известное ограничение» стоит в самом требовании, потому что +архив change'а читают реже, чем спеку. + +### D5. `review` не трогаем + +Команда «Отклонить» описана в `review` («Команды ревью и их эффекты», +`review/spec.md:33`, `:370`) — там её дом. Здесь она упоминается как **один из +путей закрытия** со ссылкой на состояние, но требование `review` не +переписывается: иначе у одного факта появилось бы два нормативных дома. + +Цена решения названа честно: `error_code` пути «Отклонить» теперь стоит в +`state-reconciliation`, а сама команда описана в `review`. Разнесение +оправдано тем, что предмет здесь — **закрытие как класс**, и сравнение двух +путей имеет смысл только рядом. + +### D6. Сценарии добавляются на оба новых утверждения + +Постановка требовала сценарий закрытия не-терминальной загрузки из веб-UI. +Добавляются четыре: веб-UI не-терминальная (пустой код), терминальная (маркер + +отказ `Cancel`), один и тот же `stuck` с двух поверхностей (маркер зависит от +поверхности), замещение прежней диагностики стоп-краном. Плюс переписан +сценарий повторного закрытия `cancelled` (no-op без записи **и** отказ `Cancel` +конфликтом). + +Выброшены два кандидата первой редакции. Сценарий «Закрытие зависшей загрузки» +с `GIVEN stuck (или failed/deferred)` обещал пользовательский путь «Закрыть» +для `deferred`, которого нет ни в одной поверхности, — ровно та ложная +гарантия, ради снятия которой задача и заведена. Сценарий «Отклонение на ревью +из Telegram» привязывал норму к кнопке карточки ревью, то есть заводил второй +дом факту, живущему в `review` (D5), и дублировал сценарий веб-UI. + +### D7. Собственный код у `Cancel` — открытый вопрос, а не отвергнутый вариант + +Первая редакция обосновывала потерю наблюдаемости так: «единый маркер +потребовал бы звать `Dismiss` из не-терминальных состояний веб-UI, то есть +менять рабочее поведение». Архитектурный проход показал, что это ложное +основание: рассматривались вариант A (звать `Dismiss` из веб-UI — да, меняет +поведение) и вариант C (ничего не делать), а **третья форма не +рассматривалась** — дать `Cancel` собственный непустой код (`user_cancel`). +Она меняет одну строку `worker.go:985`, не трогает ни состояний, ни файлов, ни +интерфейса, и восстанавливает различение полностью. + +Эта форма выходит за объявленную границу задачи: критерий приёмки A3 требует +пустого диффа под `internal/` и `web/`. Решать её не пайплайну — вопрос +записан наружу (см. «Open Questions»), а изменение урезано до остатка: +**спека перестаёт лгать и перестаёт цементировать пустоту**. Отсюда описательная +форма в D3 — будущая одностроковая починка не станет нарушением спеки и не +потребует нового change. + +Заодно назван настоящий масштаб потери, которого постановка не видела: маркер +сегодня кодирует **поверхность**, а не намерение. Один и тот же `stuck`, +закрытый из Telegram, получает `user_dismiss`, а закрытый из веб-UI — пустой +код. Это зафиксировано сценарием, чтобы вопрос не пришлось открывать заново. + +### D9. Исключение про уборку торрента висит на состоянии, а не на команде + +Правка чекпоинта 2. Редакция D8 повесила carve-out на `Cancel` — и промахнулась +осью. Уборка в `worker.go:534` срабатывает по условию `after.State != +StateCatched`, то есть безразлична к тому, какая команда увела задачу из +`catched`; `Dismiss` из `catched` даёт тот же `torrents/delete` с +`deleteFiles=true`. Проход `adversary` воспроизвёл это тестом +(`TestDismissInAddWindowCallsQbitDelete`). Гейт `Dismiss` при этом **шире** +гейта `Cancel` — значит формулировка «`Dismiss` не зовёт qBittorrent никогда» +была ложной ровно там, где ошибиться дороже всего. + +Исключение переформулировано по условию («закрытие любым путём, уведшее задачу +из `catched` в окне после `add`») — как оно и записано в `download-tracking`, +командо-нейтрально. + +Заодно убрано утверждение «и данных пользователя не касается». Оно сильнее того, +что гарантирует дом исключения: гард подтверждает отсутствие **записи торрента** +в qBittorrent, но не отсутствие **данных** на диске, и `adversary` построил путь, +на котором сносятся пред-существующие файлы пользователя +(`TestCancelInAddWindowDeletesPreExistingData`). Сам этот дефект — в коде и +пред-существующий; здесь снимается только ложная гарантия в тексте, а дефект +уходит урожаем. + +### D10. Перепривязка названа не-лекарством + +Правка чекпоинта 2. Первая редакция писала, что закрытие оставляет ссылки «без +штатной команды снятия **до повторной привязки**» — то есть подавала relink +лекарством. `adversary` показал тестами (`TestDismissRelink`, +`TestStrandedLink`), что это не так: `Apply` заводит новый батч, а `Undo` и +`Delete` работают только с последним, поэтому ссылка прежнего батча брошена +навсегда, и последующее «полное удаление» перестаёт освобождать место. Текст +приведён к факту; расширение `Undo`/`Delete` на все батчи — урожай. + +### D11. Подтверждение и лог перехода описаны как есть, а не как хотелось + +Правка чекпоинта 2. Два новых обязательства первой редакции въезжали уже +нарушенными: + +- «SHALL требовать отдельного подтверждающего шага» — без JS диалога в веб-UI + нет (гейт там только раскрытие `
` и явный submit), а в Telegram нет + danger-зоны. Норма сужена до фактического гейта: обособление плюс + дополнительный шаг там, где транспорт его поддерживает. +- «прежний код остаётся в логе перехода» — лог-строки `Cancel` и `Dismiss` + прежнюю диагностику не пишут вовсе (`worker.go:985-989`, `:1016-1020`). + Формулировка исправлена: причина восстановима только по более раннему + переходу в `failed`/`stuck` и только пока её держит ретенция логов. + +Оба раза выбран вариант «привести текст к факту», а не «оставить `SHALL` целью»: +нормативный дом с заведомо невыполненным `SHALL` — это ровно тот дефект, ради +снятия которого заведена задача. + +### D8. Отрицание побочных эффектов сужено до `Dismiss` с явным исключением + +Первая редакция обобщила «команда SHALL только менять статус, qBittorrent не +звать» с `Dismiss` на **оба** пути — и тем самым нормативно запретила уборку +воркером собственного, только что добавленного торрента при отмене в окне после +`add`. Эта уборка предписана `download-tracking` («Добавление пойманной +загрузки в qBittorrent»), закреплена тестом +`TestProcessCatchedCancelledAfterAddRemovesTorrent` и стоит вторым исключением +инварианта «источник неприкосновенен» в `CLAUDE.md`. Изменение, чья цель — +снять ложную гарантию, чуть не завело новую, да ещё и противоречащую соседней +capability. + +Отрицание возвращено к `Dismiss`, а для `Cancel` дано с явной ссылкой на дом +исключения. Общее правило, которое отсюда следует: универсальное «SHALL NOT +трогать X» обязано перечислять известные carve-out'ы или ссылаться на +требование, где они живут. + +## Risks / Trade-offs + +- **[Асимметрия поверхностей описана и тем самым узаконена, а завтра её + захотят выровнять]** → Описательная форма (D2) выравнивание не блокирует: + спека не станет ложной от того, что веб-UI добавит «Закрыть» на `stuck`. + Нормативны только гарантия закрытия и гейты доменных путей. +- **[Прецедент «спека следует за кодом» применяется буквально]** → Риск + унаследован от ADR-2026-08-06 и там же оговорён. Условие приёма проверено + поимённо и выполнено **не целиком**: для обещания доступности — да, для + обещания различимости — нет, поэтому второе спекой не закрепляется (D1, D7). + Это и есть защита от буквального применения. +- **[Новые сценарии не имеют теста-оракула]** → Изменение чисто текстовое, + гейт его регрессию не покрасит. Оракулом остаётся `openspec validate + --strict` (форма) и чтение кода (содержание). Это идёт в границы покрытия + ревью, а не замалчивается. Отдельно: клавиатуры Telegram не покрыты тестами + вовсе, поэтому описанная в требовании раскладка по этой поверхности держится + только на чтении `render.go`. +- **[Требование выросло в объёме]** → Выросло сознательно: раньше оно было + короче своей же предметной области. Раскладка клавиатур в него не заехала + (D2), поэтому косметическая правка UI его не тронет. +- **[Часть найденного ревью не закрыта этим изменением]** → Отдано урожаем, а не + замолчано (полный список с оракулами — в `review/triage.md`): гард окна после + `add` подтверждает отсутствие торрента, но не данных, и сносит + пред-существующие файлы пользователя (кандидат `critical`, воспроизведён + тестом); `Undo`/`Delete` работают только с последним батчем; прежняя + диагностика не дублируется в лог перехода; «per-download блокировка» — на + деле глобальный мьютекс, файловый ввод-вывод идёт под ним; дефолтный список + даёт `SCAN download`, а `cancelled` копятся без ретеншена; та же no-JS-дыра + в пред-существующем требовании «Полное удаление»; гонка «кнопка отрисована — + состояние ушло» не описана ни одним требованием проекта; литералы `error_code` + рассыпаны по трём спекам без сводной таблицы. +- **[Кросс-ссылки в `openspec/specs/` никем не проверяются]** → Шаг `canon` + гейта обходит только `docs/` и `CLAUDE.md`. Первая редакция принесла в спеку + markdown-ссылку, не резолвившуюся ни из change, ни после архивации; она + заменена бэктик-путём — это и есть сложившаяся в `openspec/specs/` конвенция + (markdown-ссылок там нет ни одной). Promote-кандидат отдан урожаем. + +## Open Questions + +- **Дать ли `Cancel` собственный непустой `error_code`** (напр. `user_cancel`). + Цена — одна строка `worker.go:985` плюс строка в `docs/database.md`; выигрыш — + различение «пользователь отменил активную» / «стоп-кран» / «сверка» в + диагностике восстанавливается полностью, и «известное ограничение» из + требования исчезает. Не делается здесь: выходит за границу задачи (критерий + приёмки A3 — пустой дифф под `internal/`). Пока решения нет, стоит то, что + описано: маркер кодирует поверхность, а не намерение. Рекомендация — завести: + цена несоразмерно мала. +- Стоит ли давать `dismiss` в REST ради равенства транспортов. Сегодня REST + помечен опциональным и отдаёт только `cancel`; вопрос не блокирует изменение. diff --git a/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/proposal.md b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/proposal.md new file mode 100644 index 0000000..85eaefe --- /dev/null +++ b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/proposal.md @@ -0,0 +1,68 @@ +## Why + +Требование «Ручное закрытие загрузки (стоп-кран)» обещает команду «Закрыть» +(dismiss) «из **любого** состояния, кроме `deleted`, во всех транспортах» и +метку `error_code = "user_dismiss"` на переходе. Доменная команда +`worker.Dismiss` это выполняет, но **поверхности** её так не показывают: +веб-UI держит «Закрыть» в danger-зоне только для терминальных состояний +(`Dismissable = IsTerminal() && !deleted && !cancelled`), а закрытие +не-терминальной загрузки идёт кнопкой «Отменить»/«Отклонить» → `worker.Cancel`, +которая пишет **пустой** `error_code`. Telegram показывает «Закрыть» ещё по +третьей раскладке — для `failed`/`stuck` и для `done`/`orphaned`/ +`target_missing`, а на `review`/`deferred` даёт «Отклонить» → `Cancel`. + +Расходится буква, а не поведение: `Cancel` тоже даёт `cancelled`, файлы и +раздачу не трогает, семантика для пользователя та же. Теряется маркер +`user_dismiss` в диагностике — по `error_code` не отличить «пользователь закрыл +активную» от «пользователь отменил». Пока спека утверждает единый путь закрытия, +разрыв невидим и находится заново каждым аудитом capability. + +## What Changes + +Меняется **заявленное**, не наблюдаемое. Кода изменение не трогает. + +- Требование «Ручное закрытие загрузки (стоп-кран)» разводит два уровня: + **гейты доменных путей** (`Cancel` — не-терминальные, `Dismiss` — любое кроме + `deleted`; это нормативно) и **раскладку кнопок по поверхностям** (описана как + сегодняшнее состояние, нормативной не объявляется). +- Требование называет `error_code` на каждом пути закрытия: `Dismiss` → + `"user_dismiss"` (нормативно), `Cancel` (отмена активной / отклонение на + ревью) → сегодня пустой (описательно — собственный код у `Cancel` не + запрещён, вопрос открыт). Прежняя формулировка обещала `user_dismiss` на + любом закрытии. +- Требование называет гарантию, которая держится: страница загрузки веб-UI даёт + путь закрытия из каждого не-`deleted` и не-`cancelled` состояния; какой именно + путь — выбор поверхности, а не обязательство домена. +- Отрицание побочных эффектов остаётся при `Dismiss`; для `Cancel` названо + единственное исключение — уборка воркером собственного, только что + добавленного торрента при отмене в окне после `add` (дом исключения — + `download-tracking`). +- Названо, что **оба** пути замещают прежнюю диагностику записи, а не только + `Cancel`. +- Добавляются сценарии закрытия не-терминальной загрузки из веб-UI, закрытия + терминальной, замещения диагностики и расхождения маркера по поверхностям. +- Названо известное ограничение: маркер сегодня кодирует поверхность, а не + намерение; разрыв описан с ценой, а не замолчан. + +## Capabilities + +### New Capabilities + +Нет. + +### Modified Capabilities + +- `state-reconciliation`: требование «Ручное закрытие загрузки (стоп-кран)» — + доступность закрытия по состояниям и поверхностям, `error_code` на каждом + пути закрытия, сценарии закрытия не-терминальной загрузки. + +## Impact + +- `openspec/specs/state-reconciliation/spec.md` — одно требование и его + сценарии. +- `internal/httpapi/download.go`, `web/templates/partials/download_main.html`, + `web/templates/partials/card.html`, `internal/tgbot/render.go`, + `internal/worker/worker.go` — **только чтение**, правок не предполагается. + Отсутствие правок под `internal/` и `web/` — критерий приёмки задачи. +- Прецедент приёма — [ADR-2026-08-06-spec-follows-code-on-narrow-window](../../../docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md); + второй ADR о том же не заводится. diff --git a/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/review/triage.md b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/review/triage.md new file mode 100644 index 0000000..6ef8cb9 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/review/triage.md @@ -0,0 +1,367 @@ +# Триаж чекпоинта 2 — change `cancel-dismiss-split-wording` + +Дата: 2026-08-06. Профиль: `standard`, режим: `по графу`. +База диффа: `01e64d60de7495900d9a4c8f5ce9bf811ee7b7e6`. + +## Сводка + +**Гейт:** зелёный. Кодовые шаги (build/vet/lint/gofmt/test/flaky/race/ +diff-coverage/migrations/govulncheck) — `SKIP` с причиной «кода в диффе нет»; +`canon` — `OK`; `gitleaks` — `OK`. + +**Проходы поимённо:** + +| Проход | Исход | +|---|---| +| `review-gate` | зелёный (кодовые шаги SKIP — кода в диффе нет) | +| `review-specs` | отработал, 4 находки + 4 строки границ | +| `review-code` | отработал, 1 находка + подтверждение A3 + блок «нарушений нет» | +| `review-adversary` | отработал, 5 находок (4 воспроизводящих теста), 3 свойства без пути | +| `review-reimpl` | **не запускался** — стадия 3, только `deep`; вне профиля `standard` | +| `review-architecture` | **не запускался на этом чекпоинте** — стадия 4 (`wide`/`deep`); отработал на чекпоинте 1 в профиле `design`, находки закрыты правками предложения (design.md, D1–D3, D6–D8) | +| `review-ops` | отработал, 3 находки + ответы на обязательные вопросы | + +Состав запущенного сверен с профилем `standard` — расхождений нет. + +**Счёт находок:** на входе 13 (S1–S4, C2, A1, A2, A3adv, A4, A5, O1–O3; C1 — +подтверждение приёмки, не находка). После дедупа — 10 (S1=A3adv, C2=A4, +S4=A5). Разложено: 3 блокируют, 4 стоит исправить сейчас, 0 гипотез, +6 в урожай, 1 promote-кандидат. + +**Состояние change:** файлы staged, но не закоммичены (`HEAD` == база; дифф +worktree против базы — 5 файлов, 514 вставок, все под +`openspec/changes/cancel-dismiss-split-wording/`). Перед архивацией нужен +коммит — это состояние прогона, не находка. + +**Вердикт по архивации:** change **годится к архивации после инлайн-правок** +секций 1–2. Находки, требующей остановки или переработки предложения, нет: +все семь пунктов — правки текста дельты, локальные и однозначные, кроме одной +развилки (п. 7, выбор регистра нормы о подтверждении). Дефекты в коде, +вскрытые тестами adversary, — пред-существующие, изменением не введены и не +задеты (критерий A3 требует пустой кодовый дифф); они уходят урожаем. + +## Приёмка A1–A3 (доклад приёмщику, не отметка «принято») + +- **A1** — требование различает `Cancel`/`Dismiss` по состояниям (не-терминальные + против «любое кроме `deleted`», конфликт против no-op) и называет `error_code` + обоих путей (`user_dismiss` против пустого). Оракул: + `openspec validate --strict cancel-dismiss-split-wording` → `Change … is valid`, + exit 0 (прогнано мной). **Выполнен**, с оговоркой: нормативная фраза «SHALL + называть путь закрытия» внутренне противоречит пустому коду `Cancel` — п. 4 ниже. +- **A2** — сценарий «Закрытие не-терминальной загрузки из веб-UI идёт отменой» + (дельта, строки 112–119): исход `cancelled`, `error_code` назван (пустой). + Оракул: тот же прогон + чтение сценария. **Выполнен.** +- **A3** — `git diff --stat <база> -- internal web cmd` — **пусто** (прогнано + мной по worktree; полный дифф — 5 файлов только под `openspec/changes/`). + Гейт `Dismissable` и danger-zone шаблона не тронуты. **Выполнен.** + +--- + +## Блокирует мердж (архивацию) + +Все три — корзина **(а): дефект введён текстом этого изменения**. Все три — +правки одного-двух абзацев дельты +`openspec/changes/cancel-dismiss-split-wording/specs/state-reconciliation/spec.md`. + +### 1. Дельта гарантирует «`Dismiss` SHALL NOT вызывать qBittorrent» — гарантия ложна: `Dismiss` из `catched` в окне после `add` даёт `torrents/delete` с `deleteFiles=true` + +- Файл: дельта, строки 36–47; код `internal/worker/worker.go:520–556` (уборка + по условию `after.State != store.StateCatched`, безразлична к команде), + `internal/httpapi/review.go:384–391` (маршрут `dismiss` по состоянию не + гейтится — путь достижим сегодня прямым POST) +- Severity: major, Confidence: high. Корзина: **(а)** — сам механизм уборки + пред-существует и нормирован командо-нейтрально в `download-tracking` + (строки 216–234); ложна только атрибуция исключения одной команде `Cancel`. +- Оракул (прогнан мной): `go test ./tmp/adv/ -run TestDismissInAddWindow -v` → + «ADV-3 воспроизведён: закрытие путём Dismiss (error_code="user_dismiss") + вызвало torrents/delete deleteFiles=[true]». Плюс + `internal/worker/catched_test.go:244–268` — штатный тест уборки ставит + `cancelled` напрямую, не через `Cancel`. +- Дедуп: S1 (`specs`) + A3adv (`adversary`) — одна причина: исключение в коде + привязано к состоянию/окну, а не к команде. +- Действие: **инлайн**. Переформулировать исключение по условию, а не по + команде: «закрытие **любым путём**, уведшее задачу из `catched` в окне между + нашим `add` и записью перехода» — как это уже сформулировано в + `download-tracking`. Тем же правом убрать из абзаца атрибуцию «`Cancel` тем же + ограничен, с одним исключением» — исключение общее для обоих путей. +- **В ту же правку входит C2/A4** (дедуп: одна ссылка, одно предложение): + markdown-ссылка `../../../specs/download-tracking/spec.md` (строка 44) не + резолвится **ни из позиции дельты** (нормализуется в + `openspec/changes/specs/download-tracking/spec.md`, файла нет; верно было бы + 4 уровня), **ни после архивации** (из `openspec/specs/state-reconciliation/` + нормализуется в `specs/download-tracking/spec.md`; верно — 1 уровень). + Единого относительного пути, верного в обеих позициях, не существует. + Проверено арифметикой пути (`os.path.normpath` + `exists`, прогнано мной). + Фикс: дать ссылку **бэктиком** — `openspec/specs/download-tracking/spec.md` — + как во всех соседних кросс-ссылках capability (прецедент: + `openspec/specs/state-reconciliation/spec.md:92`); markdown-ссылок в + `openspec/specs/` нет ни одной (grep пуст), и `docs.py check` этот каталог + не проверяет — битую ссылку не покрасил бы никто. + +### 2. Дельта заявляет, что уборка в окне после `add` «данных пользователя не касается» — безусловное утверждение сильнее того, что гарантирует код: пред-существующие файлы под `paths.downloads` уничтожаются + +- Файл: дельта, строки 45–46; код `internal/worker/worker.go:495–556` (гард + «подтверждённое отсутствие» проверяет отсутствие **торрента**, не отсутствие + **данных**) +- Severity: major (для текста; кодовый дефект — кандидат `critical`, см. урожай + У-1), Confidence: high для текстовой части. Корзина: **(а)** для текста / + **(в)** для кода — дыра в гарде пред-существует, изменением не задета; этим + change введена только ложная гарантия. +- Оракул (прогнан мной): `go test ./tmp/adv/ -run TestCancelInAddWindow -v` → + «ADV-1 воспроизведён: worker вызвал torrents/delete deleteFiles=[true]; + данные …/downloads/Show/e1.mkv существовали под paths.downloads ДО add + (гард проверял отсутствие торрента, а не отсутствие данных)». +- Почему текст — major, а не перенос `critical` с кода: текст сам данных не + уничтожает; но, будучи влит в нормативный дом, он узаконивает дыру — следующий + аудит поверит «данных не касается» и закроет расследование. Severity кода на + severity текста автоматически не переносится; кодовый дефект едет урожаем со + своим весом. +- Действие: **инлайн**. Убрать безусловное «и данных пользователя не касается». + Right-size замена — отослать к дому нормы без собственной гарантии + («нормирована в `download-tracking`»), не расширяя дельту признанием дыры: + дыра — предмет задачи У-1, а не этого требования. + +### 3. Обещание «библиотечные ссылки без штатной команды снятия **до повторной привязки**» ложно: перепривязка их не снимает никогда — `Undo`/`Delete` работают только с последним батчем + +- Файл: дельта, строки 62–66 и сценарий 93–101; код + `internal/worker/review.go:332` (каждый `Apply` — новый батч), `:557–572` + (`Undo` — только `LatestBatchID`), `:625–641` (`Delete` — только + `LatestBatchID`) +- Severity: major, Confidence: high. Корзина: **(а)** для формулировки / + **(в)** для кода (поведение батчей пред-существует, изменением не задето). +- Оракул (прогнан мной): `go test ./tmp/adv/ -run 'TestDismissRelink|TestStrandedLink' -v` → + «ADV-2: в библиотеке осталась неуправляемая ссылка (батч 1), которую не + снимает ни Undo, ни Delete»; «ADV-2b: запись в deleted, раздача снесена с + файлами, но в библиотеке осталась неуправляемая ссылка с живыми данными — + место не освобождено». +- Последствие текста: единственный маршрут к брошенной ссылке — ровно та связка + `Dismiss` из `done` → relink, которую дельта нормирует; «до повторной + привязки» называет relink лекарством, которым он не является, а после него + «полное удаление» перестаёт освобождать место — danger-зона обещает обратное. +- Действие: **инлайн**. Заменить «до повторной привязки» честным: «…без штатной + команды снятия; последующая перепривязка их **не** снимает — `Undo` и + `Delete` работают только с последним батчем раскладки». Расширение + `Undo`/`Delete` на все батчи — урожай У-2. + +## Стоит исправить сейчас + +### 4. Нормативное «`error_code` SHALL называть путь закрытия» нарушено пустым кодом `Cancel` и опровергнуто тем же документом тремя абзацами ниже + +- Файл: дельта, строка 49; код `internal/worker/worker.go:985` + (`SetDownloadState(ctx, id, StateCancelled, "", "")` — проверено чтением) +- Severity: major, Confidence: high. Корзина: **(а)** — фраза введена этим + изменением. +- Оракул: код (пустой код ничего не «называет») + сам документ: строки 56–58 + («пустота кода на пути `Cancel` … не закреплена нормативно») и 73–82 + («известное ограничение»). `SHALL`, который документ сам объявляет + неудовлетворённым, — приглашение следующему аудиту завести ту же задачу. +- Действие: **инлайн**. Перевести фразу в согласованный регистр: например, + «`error_code` перехода SHALL **различать** пути закрытия, а не унифицировать + их» (пустой у `Cancel` против `user_dismiss` у `Dismiss` — различает), либо + сузить нормативность до `Dismiss` (его `SHALL user_dismiss` уже стоит строкой + ниже). Остальной абзац и «известное ограничение» уже согласованы. + +### 5. «Прежняя диагностика остаётся только в логе перехода» — фактически неверно: лог-строки перехода `Cancel`/`Dismiss` прежних `error_code`/`error_msg` не содержат + +- Файл: дельта, строки 53–58 и сценарий 103–110; код + `internal/worker/worker.go:985–991, 1016–1022` — проверено чтением: `Cancel` + логирует только `from`/`to`, `Dismiss` — `from`/`to`/`code` **нового** + перехода; контракт поля `code` — `docs/conventions/logging.md:26–30`. +- Severity: major, Confidence: high. Корзина: **(а)** для формулировки / + **(б)** для наблюдаемости (лог и раньше не нёс прежней диагностики — урожай + У-3). +- Последствие: причина падения восстановима только по более ранней, никак не + связанной по смыслу лог-строке, чья доступность зависит от неописанной + ретенции логов (`docs/architecture.md` — только «логи в stdout контейнера»). +- Действие: **инлайн**. Поправить формулировку: прежние код и текст «в записи + не сохраняются; лог-строка перехода их не дублирует — они остаются лишь в + более ранних строках лога состояния». Добавление `prev_error_code`/`prev_error_msg` + в лог — кодовая правка, запрещённая критерием A3, → урожай У-3. + +### 6. Описанная раскладка Telegram врёт в трёх клетках построенной по ней таблицы «состояние × поверхность» + +- Файл: дельта, строки 30–34; код `internal/tgbot/bot.go:452–461`, + `internal/tgbot/render.go:44–50, 281–290` — проверено чтением: уведомление + `EventDone` — текст без клавиатуры; `EventTargetMissing`/`EventOrphaned` — + только ссылка в веб (`webOnly`, и та `nil` при пустом `web_base_url`); + `StateLinking` — клавиатуры нет вовсе (не «отсылает в веб»). «Закрыть» + появляется на **карточке** (`renderCard`/refresh), а не в уведомлении. +- Severity: minor, Confidence: high. Корзина: **(а)** — перечень введён этим + изменением (текст описателен: «сегодня раскладка такова», но неверен). +- Последствие: реальный пробел — уведомление о рассинхроне приходит без + стоп-крана — текстом замаскирован. +- Действие: **инлайн**. Либо уточнить: «Telegram даёт „Закрыть“ на **карточке** + загрузки в `failed`/`stuck`/`done`/`orphaned`/`target_missing`; уведомления + о `done` и рассинхроне кнопок закрытия не несут», либо снять перечень + Telegram целиком, оставив нормативную гарантию только за страницей загрузки + (раскладка по поверхностям всё равно объявлена ненормативной). + +### 7. Новый `SHALL` об «отдельном подтверждающем шаге» въезжает уже нарушенным: без JS диалога нет, в Telegram нет ни danger-зоны, ни подтверждения + +- Файл: дельта, строки 68–71; код + `web/templates/partials/download_main.html:95–98` — проверено чтением, + комментарий дословно: «без JS гейт — только раскрытие details и явный submit + (диалога нет)»; `internal/tgbot/render.go:266–290` — «Закрыть» в общем ряду + кнопок. `openspec/specs/web-ui/spec.md` нормирует «без JavaScript не + ломается». +- Severity: minor, Confidence: medium. Корзина: **(а)** — прежняя редакция + обязательства не содержала (уточнение adversary), норма введена этим + изменением; тот же изъян в пред-существующем требовании «Полное удаление + загрузки пользователем» — корзина (в), урожай У-6. +- Дедуп: S4 (`specs`) + A5 (`adversary`) — одна причина. +- Действие: **развилка** — выбор регистра нормы, а не правка формулировки: + 1. **Сузить норму до фактического гейта**: «„Закрыть“ SHALL размещаться в + обособленной danger-зоне, скрытой по умолчанию (раскрытие — сознательный + шаг); с JS дополнительно SHALL показываться диалог подтверждения» + назвать + известным ограничением, что Telegram danger-зоны не даёт. Цена: слабее + звучит R7, зато спека не лжёт и код не требуется. + 2. **Оставить `SHALL` как цель** и завести урожаем задачу: no-JS-подтверждение + (например, промежуточная страница-подтверждение) и danger-гейт в Telegram. + Цена: до выполнения задачи нормативный дом содержит невыполненный `SHALL` + (ровно то, за что бьёт п. 4). + Рекомендация триажа — вариант 1: он согласуется с R12 («спека фиксирует + наблюдаемое») и с решением п. 4. + +## Гипотезы без доказательства + +Пусто. Все выжившие находки имеют прогнанный оракул (тест, чтение названных +строк кода, арифметика пути, вывод команды). Понижений за отсутствие оракула +не было; `critical` кода из A1 не понижен, а отделён от текстовой находки и +уехал урожаем со своим весом (У-1) — его единственный непрогнанный шаг назван +там честно. + +Отдельно: совпадение S1/A3adv и S4/A5 в разных проходах учтено как рост +приоритета, не как рост Confidence — под всеми проходами одна модель. + +## Promote candidates + +- **Проверка ссылок в `openspec/`** — шаг `canon` (`docs.py check`) обходит + только `docs/` и `CLAUDE.md`; битую markdown-ссылку в спеке не красит никто + (п. 1/C2 прожил бы до архивации молча). Кандидат: расширить проверку битых + ссылок на `openspec/specs/` и `openspec/changes/`, либо записать конвенцией + «кросс-ссылки в спеках — только бэктиком» (сегодня это фактическая, но + нигде не записанная практика: markdown-ссылок в `openspec/specs/` ноль). + Провенанс: `review-code` C2 + `review-adversary` A4. + +## Урожай (пред-существующие дефекты — владельцу задач, не в этот change) + +- **У-1 (кандидат `critical`, Confidence: medium).** Отмена/закрытие в окне + после `add` сносит **пред-существующие** файлы пользователя под + `paths.downloads`: гард «подтверждённое отсутствие» (`worker.go:495–509`) + проверяет отсутствие торрента в qBittorrent, но не отсутствие данных на + save_path; `qbt.Add` подхватывает лежащие там файлы, уборка бьёт + `torrents/delete` с `deleteFiles=true`. Путь: пользователь снял раздачу, + файлы оставив (это предписывает сам `download-tracking:169–174` как + восстановление) → подал тот же торрент → отменил в окне листинг+add (сотни + мс — секунды). Инвариант «источник неприкосновенен»: исключение (2) покрывает + «собственный торрент», но не пред-существующие данные; UI при этом обещает + «файлы и раздача останутся нетронутыми» (`download_main.html:100–111`). + Оракул: `tmp/adv/adv_test.go`, `TestCancelInAddWindowDeletesPreExistingData` — + прогнан, воспроизводит (каталог gitignored — при заведении задачи тест + перенести в её материалы). Единственный непрогнанный шаг — семантика боевого + qBittorrent `deleteFiles=true` над пред-существующими файлами: в бой ходить + запрещено, отсюда medium. Провенанс: `review-adversary` A1, чекпоинт 2 + change `cancel-dismiss-split-wording`. +- **У-2 (major, Confidence: high).** `Undo`/`Delete` работают только с + последним батчем (`review.go:557–572, 625–641`), каждый `Apply` создаёт новый + (`review.go:332`): связка «закрыть `done` → relink» бросает ссылку батча 1 + навсегда, а «полное удаление» после этого не освобождает место (инод жив за + счёт брошенной ссылки). Оракул: `TestDismissRelinkStrandsLibraryLinks`, + `TestStrandedLinkDefeatsDelete` — прогнаны, воспроизводят. Провенанс: + `review-adversary` A2. +- **У-3 (minor, наблюдаемость).** Лог-строки переходов `Cancel`/`Dismiss` не + несут прежних `error_code`/`error_msg`, хотя `d` их держит в момент записи + (`worker.go:985–991, 1016–1022`): «почему задача упала» после закрытия не + восстановить одной строкой. Кандидат: `prev_error_code`/`prev_error_msg` в + лог перехода. Оракул: чтение кода + контракт `logging.md:26–30`. Провенанс: + `review-ops` O1. +- **У-4 (minor).** «Per-download блокировка» из `CLAUDE.md` (инвариант + «переходы состояний») и `worker.go:11` — фактически один глобальный + `sync.Mutex` (`worker.go:239`), удерживаемый `Apply` на весь файловый + ввод-вывод: долгая раскладка блокирует команды по **другим** загрузкам и + поллинг. Минимум — поправить формулировку в двух местах; по существу — + per-id блокировка либо вынос I/O из-под замка. Оракул: чтение кода (одно + поле, не карта). Провенанс: `review-ops` O2. +- **У-5 (minor, эффект — гипотеза при сегодняшнем профиле «единицы/день»).** + Дефолтный список — `SCAN download` (негативный `state NOT IN` мимо + `idx_download_state`, измерено `EXPLAIN QUERY PLAN` на копии схемы), а + `cancelled` копятся без ретенции, и требование под ревью расширяет канал их + производства. Частичный индекс или позитивный список состояний; ретеншен — + задача в беклоге уже есть. Провенанс: `review-ops` O3. +- **У-6 (minor).** Пред-существующее требование «Полное удаление загрузки + пользователем» (`openspec/specs/state-reconciliation/spec.md:463–465`) несёт + ту же формулировку о подтверждающем шаге с той же no-JS-дырой, что п. 7. + Судьба зависит от развилки п. 7 — править согласованно. Провенанс: + `review-specs` S4 (смягчающее наблюдение). + +Уже назначено урожаем ранее (не дублирую задачей): R10 из рубрики чекпоинта 1 — +«допустимость команды переоценивается на момент исполнения, а не по снимку +рендера» (`tasks.md`); туда же примыкает граница `specs` про `/review/{id}`, +открытый для любого состояния. + +## Границы покрытия + +**Запускалось:** профиль `standard`, режим «по графу», чекпоинт 2 (после +apply). `review-gate` (кодовые шаги SKIP — кода в диффе нет; canon, gitleaks — +OK), `review-specs`, `review-code`, `review-adversary`, `review-ops`. + +**Не запускалось:** `review-reimpl` — стадия 3, только в `deep`; +`review-architecture` — стадия 4 (`wide`/`deep`); оба вне профиля `standard`. +`review-architecture` отработал на чекпоинте 1 (профиль `design`) вместе с +`review-specs` и `review-rubric`; его находки закрыты правками предложения +(design.md, решения D1–D3, D6–D8) и на этом чекпоинте не перепроверялись. + +**Что запущенные проходы не могли проверить в принципе (из charter'ов):** + +- `specs`: поведение живых qBittorrent/Telegram — клавиатуры Telegram не + покрыты тестами вовсе, сверка была чтением кода; плюс его заявленные границы: + владение целевым путём после закрытия `done` (ушло в У-2), `/review/{id}` + без гейта состояния (примыкает к R10), «активный список» как три разных + множества (`store/list.go`, `store/download.go`). +- `code`: только записанные конвенции; конвенции о форме OpenSpec-спек в + `docs/conventions/` нет — норму задавали `openspec/config.yaml` и `CLAUDE.md`. +- `adversary`: семантика **боевого** qBittorrent (`deleteFiles=true` над + пред-существующими файлами) — в бой ходить запрещено; это единственный + непрогнанный шаг У-1. Свойства без пути: снятие последней копии + последовательностью команд не строится; секретов в `error_msg`/логах не + найдено; негейченный маршрут `dismiss` в сегодняшнем периметре + (`docs/security.md`) — операторское действие, не дефект (совпадает с + «Типовым ложноположительным» про отсутствие авторизации — корректно не + выведен находкой). +- `ops`: `-race` на этом диффе не гонялся (кода нет — SKIP); гонки суждены + рассуждением. Ретенция логов stdout нигде не описана — доступность «более + ранней лог-строки» из У-3 неизвестна. +- триаж (я): нового не ищу по определению — работаю с чужими выводами; пропуск + любого прохода — мой пропуск тоже. Тесты `tmp/adv/` прогнаны и воспроизводят, + но их фикстуры — фейковый qbt и локальная ФС: они доказывают поведение + **нашего** кода, не связки с боем. + +**Осталось целиком на человеке** (из `docs/review.md`, два списка раздельно): + +*Не проверит ни один проход:* +- история инцидентов на umbar и что уже ломалось в проде; +- поведение SQLite под реальным объёмом и профилем нагрузки; +- завязка внешних потребителей (Jellyfin, закладки, чужие ссылки) на текущее + поведение; +- качество распознавания (корпус решено не собирать, REJECTED 2026-08-06); +- суждение «этой функциональности не должно существовать». + +*Перестали проверять сознательно:* +- идиоматичность Go — с 2026-08-04, вместе с упразднением прохода `idiom` при + переезде на плагин; различение «идиоматично против распространено» не + спрашивает никто; пересмотр — задача `quality-review-agents`. + +**Каких документов/фактов не хватило (поразрядно, с причиной):** + +- `docs/conventions/` — конвенции о форме OpenSpec-спек нет: правило «ссылки + бэктиком, не markdown» пришлось выводить grep'ом по фактике (`review-code`); +- `docs/architecture.md` — ретенция логов не описана (только «stdout + контейнера»): судьба «прежней диагностики в логе» из У-3 неопределима + (`review-ops`); +- `docs/review.md`, журнал дефектов — пуст (заведён 2026-07-23): оракулов + «такое здесь уже воспроизводилось» не было ни для одной находки, все + подтверждения добывались тестами и чтением кода (триаж); +- `CLAUDE.md`, инварианты — присутствуют и использованы (ранжирование У-1 по + «необратимо» и границе исключения (2)); «Типовые ложноположительные» и оба + подраздела «Недоступно проверке» — присутствуют и использованы. Здесь + пробелов нет. diff --git a/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/specs/state-reconciliation/spec.md b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..5f586a5 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/specs/state-reconciliation/spec.md @@ -0,0 +1,170 @@ +## MODIFIED Requirements + +### Requirement: Ручное закрытие загрузки (стоп-кран) + +Система SHALL давать пользователю возможность **закрыть** загрузку — перевести +её в терминальное `cancelled`, убрав из активного списка/внимания, — из +**любого** состояния, кроме `deleted`. Страница загрузки веб-UI +(`/download/{id}`) SHALL предоставлять путь закрытия из каждого не-`deleted` и +не-`cancelled` состояния; список загрузок (`/`) ограничиваться действиями над +активными загрузками MAY. Загрузку в `deleted` закрывать система SHALL NOT +(состояние строго терминально). + +Путей закрытия **два**, и они разведены гейтом терминальности: + +- **«Отменить»/«Отклонить»** (`Cancel`) — отмена **активной** работы. Команда + SHALL быть доступна только из не-терминальных состояний и SHALL отклоняться + с конфликтом из терминальных, включая `cancelled`. +- **«Закрыть»** (`Dismiss`) — стоп-кран для загрузки, которую уже никто не + двигает: зависшей, спорной или лишней (в т.ч. дубля-близнеца в + `target_missing`, чьи файлы разложены другой загрузкой). Команда SHALL быть + доступна из любого состояния, кроме `deleted`, включая терминальные, где + `Cancel` уже отказывает. В `cancelled` `Dismiss` SHALL быть идемпотентным + no-op **без записи состояния** — иначе он подменил бы `error_code` прежнего + закрытия. + +Раскладка путей по поверхностям нормативной **не является**: поверхность +выбирает путь по состоянию, и требовать от каждой поверхности **оба** пути +система SHALL NOT. Сегодня раскладка такова: страница загрузки веб-UI даёт +«Закрыть» в danger-зоне из терминальных состояний, кроме `deleted` и +`cancelled`, а из не-терминальных закрывает кнопкой «Отменить» (на экране +ревью — «Отклонить»); Telegram даёт «Закрыть» из `failed`, `stuck`, `done`, +`orphaned`, `target_missing`; «Отклонить» — на карточке ревью +(`review`/`deferred`). Кнопка закрытия в Telegram живёт на **карточке** задачи; +уведомления о готовности и о рассинхроне её сегодня не несут, а из `linking` +карточка не даёт действий вовсе. REST закрытие предоставлять MAY (сегодня +отдаёт только `cancel`). + +Ни один путь закрытия SHALL NOT производить действий с файлами или библиотечными +ссылками: система SHALL NOT удалять либо создавать хардлинки под +`paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие библиотечные +ссылки сознательно остаются на месте. qBittorrent закрытие вызывать SHALL NOT — +раздача не снимается и продолжает раздаваться, — с **одним** исключением, и оно +привязано к состоянию, а не к команде: если закрытие **любым** путём увело +задачу из `catched` в окне между нашим `add` и записью перехода, worker убирает +только что добавленный торрент. Уборка нормирована в +`openspec/specs/download-tracking/spec.md`, требование «Добавление пойманной +загрузки в qBittorrent», и её границы задаёт оно — здесь они не пересказываются +и не расширяются. Синхронный source-preflight ни один из путей выполнять SHALL +NOT (источник в действии не участвует). + +`error_code` перехода SHALL **различать** пути закрытия, а не унифицировать их. +`Dismiss` SHALL помечать переход `error_code = "user_dismiss"` +(человекочитаемая причина — в `error_msg`), отличая стоп-кран от отклонения на +ревью, от отмены активной работы и от удаления. Оба пути при этом SHALL замещать +прежнюю диагностику записи: `Dismiss` — на `user_dismiss` с причиной закрытия, +`Cancel` — сегодня на пустые `error_code`/`error_msg`; прежний код состояния +(`stalled` у `stuck`, `qbit_error` у `failed`) из записи пропадает безвозвратно. +Лог перехода закрытия его не дублирует: восстановить причину можно только по +более ранней строке перехода **в** `failed`/`stuck`, пока её держит ретенция +логов. Пустота кода на пути `Cancel` описана как сегодняшнее поведение, а не +закреплена нормативно: собственный непустой код у `Cancel` требованием не +запрещён. + +Новый статус ни для одного из путей система вводить SHALL NOT — +переиспользуется существующее терминальное `cancelled` (сверка его не +переоценивает). Из `cancelled` пользователю остаётся доступной перепривязка +(relink), если он передумает; при этом `Undo` и `Delete` из `cancelled` +доступны SHALL NOT (их пол — `done`/`orphaned`/`target_missing`). Закрытие +разложенной загрузки поэтому оставляет её библиотечные ссылки без штатной +команды снятия, и **перепривязка их не снимает**: `Undo` и `Delete` работают +только с последним батчем раскладки, а повторное применение заводит новый. +Снять ссылки прежнего батча система средствами не даёт. + +В интерфейсе «Закрыть» SHALL размещаться в отдельной «danger zone» (напр. внизу +страницы загрузки), обособленно от штатных действий, и SHALL быть отделена от +одиночного клика **дополнительным шагом**: раскрытием danger-зоны и явным +подтверждением там, где транспорт его поддерживает (диалог веб-UI при доступном +JS, второй шаг клавиатуры в Telegram). «Отменить»/«Отклонить» подтверждения +требовать SHALL NOT: команда отменяет работу, которая ещё идёт. + +Отсюда следует **известное ограничение**: по `error_code` отличима «загрузка +закрыта стоп-краном» от «загрузка отменена активной», но не «пользователь +закрыл активную загрузку» от «пользователь отменил её» — оба пути на +не-терминальном состоянии дают один и тот же пустой код. Хуже того, маркер +сегодня кодирует не намерение, а поверхность: одно и то же состояние `stuck` +закрывается из веб-UI отменой (пустой код), а из Telegram стоп-краном +(`user_dismiss`). Цена — потеря различения в логах и диагностике; поведение и +данные не страдают. Восстановить различение можно, дав `Cancel` собственный +непустой код, — это требованием разрешено и оставлено открытым вопросом, а не +отвергнуто. + +#### Scenario: Закрытие записи без цели не трогает раздачу + +- **GIVEN** загрузка в `target_missing`: источник присутствует в qBittorrent, + целевых хардлинков нет (напр. её файлы разложены другой загрузкой) +- **WHEN** пользователь даёт команду «Закрыть» +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** раздача с файлами в qBittorrent не удаляется +- **AND** запись пропадает из активного списка + +#### Scenario: Закрытие done оставляет библиотечные файлы на месте + +- **GIVEN** загрузка в `done` с существующими библиотечными хардлинками +- **WHEN** пользователь даёт команду «Закрыть» +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** библиотечные хардлинки не удаляются +- **AND** раздача в qBittorrent не снимается +- **AND** команды `Undo` и `Delete` на записи становятся недоступны, а + «Привязать заново» остаётся доступной +- **AND** после перепривязки и повторного применения ссылки прежнего батча + остаются в библиотеке — их не снимает ни `Undo`, ни `Delete` + +#### Scenario: Стоп-кран замещает прежнюю диагностику состояния + +- **GIVEN** загрузка в `failed` с `error_code = "qbit_error"` и текстом ошибки + в `error_msg` +- **WHEN** пользователь даёт команду «Закрыть» +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** прежний код и текст ошибки в записи не сохраняются +- **AND** лог перехода закрытия их не дублирует — причина отказа восстановима + только по более раннему переходу в `failed` + +#### Scenario: Закрытие не-терминальной загрузки из веб-UI идёт отменой + +- **GIVEN** загрузка в не-терминальном состоянии (`downloading`, `recognizing`, + `review`, `deferred`, `stuck`), её страница открыта в веб-UI +- **WHEN** пользователь нажимает «Отменить» (на экране ревью — «Отклонить») +- **THEN** запись переходит в `cancelled` и пропадает из активного списка +- **AND** `error_code` и `error_msg` перехода пусты (маркера `user_dismiss` на + этом пути нет) +- **AND** ни раздача в qBittorrent, ни хардлинки не трогаются + +#### Scenario: Стоп-кран доступен там, где отмена уже отказывает + +- **GIVEN** загрузка в терминальном состоянии, кроме `deleted` и `cancelled` + (`done`, `failed`, `reverted`, `target_missing`, `orphaned`) +- **WHEN** пользователь даёт команду «Закрыть» и подтверждает её +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** команда `Cancel` на том же состоянии отклоняется с конфликтом + +#### Scenario: Один и тот же stuck закрывается разными путями с разных поверхностей + +- **GIVEN** загрузка в `stuck` +- **WHEN** пользователь закрывает её из Telegram кнопкой «Закрыть» +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** та же загрузка, закрытая со страницы веб-UI кнопкой «Отменить», даёт + `cancelled` с пустым `error_code` — по коду перехода поверхности не + различить от намерения + +#### Scenario: «Закрыть» недоступна для deleted + +- **GIVEN** загрузка в `deleted` +- **WHEN** пользователь пытается вызвать «Закрыть» +- **THEN** команда недоступна, состояние остаётся `deleted` + +#### Scenario: Повторное закрытие не подменяет причину прежнего + +- **GIVEN** загрузка в `cancelled`, закрытая ранее отменой (пустой `error_code`) +- **WHEN** приходит команда «Закрыть» +- **THEN** команда проходит no-op'ом, записи состояния не происходит +- **AND** `error_code` остаётся прежним (пустым), а не подменяется на + `user_dismiss` +- **AND** команда «Отменить» на том же состоянии отклоняется с конфликтом + +#### Scenario: Закрытую запись можно привязать заново + +- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled` +- **WHEN** пользователь даёт команду «Привязать заново» +- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink + из `cancelled`) diff --git a/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/tasks.md b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/tasks.md new file mode 100644 index 0000000..be11825 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/tasks.md @@ -0,0 +1,64 @@ +## 1. Спека + +- [x] 1.1 Влить дельту в `openspec/specs/state-reconciliation/spec.md` + (требование «Ручное закрытие загрузки (стоп-кран)» целиком) — делает + `opsx:archive`/`opsx:sync`, руками спека не правится. +- [x] 1.2 Прогнать `openspec validate --strict cancel-dismiss-split-wording`. + +## 2. Код + +- [x] 2.1 Правок нет и быть не должно: `internal/httpapi/download.go`, + `web/templates/partials/download_main.html`, `internal/tgbot/render.go`, + `internal/worker/worker.go` — только чтение. Проверка — пустой + `git diff --stat` под `internal/` и `web/`. + +## 3. Верификация + +- [x] 3.1 `task gate` зелёный. +- [x] 3.2 Ревью изменения (второй чекпоинт) проведено, триаж сохранён в + `openspec/changes/cancel-dismiss-split-wording/review/`. + +## Рубрика ревью предложения (проход `review-rubric`, чекпоинт 1) + +Свойства, по которым судится требование о пользовательской команде, переводящей +сущность в терминальное состояние. Порождены до чтения предмета; здесь стоят +приёмочными критериями наравне с A1–A3. + +- [x] R1. Отрицания побочных эффектов сверены с carve-out'ами инвариантов: у + каждого «SHALL NOT трогать X» есть либо сценарий, либо ссылка на требование, + где X всё-таки трогается. +- [x] R2. Пол доступности задан перечислением состояний, названо, что бывает при + вызове из недопустимого (конфликт или no-op). +- [x] R3. Идемпотентность повтора определена **по каждому пути отдельно**, без + обобщающего «закрытие». +- [x] R4. Различимость инициатора зафиксирована литералом; где различение + теряется — записано ограничением, а не умолчано. +- [x] R5. Судьба прежней диагностики названа для обоих путей. +- [x] R6. Обязательства поверхностей фальсифицируемы: по тексту строится таблица + «состояние × поверхность»; круговых формулировок нет. +- [x] R7. Подтверждение опасного нормировано в теле требования, а не только в + сценарии одной поверхности — **по фактическому гейту**: обособление плюс + дополнительный шаг там, где транспорт его поддерживает (чекпоинт 2, D11). +- [x] R8. Названо, какие штатные команды из полученного терминального состояния + отпадают. +- [x] R9. Конкурентность разрешена правилом «кто первый» (единый сериализующий + узел). +- [ ] R10. Допустимость команды переоценивается на момент исполнения, а не по + снимку рендера. **Не закрыто этим изменением** — отдано урожаем: свойство + общее для всех команд проекта, дом ему не в этом требовании. +- [x] R11. Терминальность имеет описанный выход (relink) и согласована со + сверкой. +- [x] R12. Спека фиксирует наблюдаемое, а не внутреннее: косметическая правка UI + не делает её ложной. + +## Критерии приёмки (из постановки `docs/tasks/items/dismiss-marker-lost.md`) + +- [x] A1. Требование спеки различает `Cancel` и `Dismiss` по состояниям и + называет `error_code` для каждого пути закрытия. + **Оракул:** `openspec validate --strict`. +- [x] A2. В спеке есть сценарий «пользователь закрывает не-терминальную + загрузку из веб-UI» с исходом `cancelled` и названным `error_code`. + **Оракул:** тот же прогон. +- [x] A3. Гейт `Dismissable` в коде и danger-zone шаблона остаются как есть. + **Оракул:** `git diff --stat` в отчёте ревью — файлов под `internal/` и + `web/` нет. diff --git a/openspec/specs/state-reconciliation/spec.md b/openspec/specs/state-reconciliation/spec.md index d85d881..1a1a4b3 100644 --- a/openspec/specs/state-reconciliation/spec.md +++ b/openspec/specs/state-reconciliation/spec.md @@ -540,31 +540,92 @@ SHALL задевать раскладку в полёте. ### Requirement: Ручное закрытие загрузки (стоп-кран) -Система SHALL предоставлять пользователю команду **«Закрыть»** (dismiss), -доступную из **любого** состояния, кроме `deleted`, во всех транспортах (веб-UI и -Telegram, опц. REST). Команда SHALL переводить загрузку в терминальное -`cancelled`, убирая её из активного списка/внимания, и SHALL служить -универсальным стоп-краном для любой зависшей или спорной загрузки (в т.ч. лишнего -дубля-близнеца в `target_missing`, чьи файлы уже разложены другой загрузкой). Для -загрузки в `deleted` команда доступна SHALL NOT (состояние строго терминально); в -`cancelled` команда SHALL быть идемпотентным no-op. +Система SHALL давать пользователю возможность **закрыть** загрузку — перевести +её в терминальное `cancelled`, убрав из активного списка/внимания, — из +**любого** состояния, кроме `deleted`. Страница загрузки веб-UI +(`/download/{id}`) SHALL предоставлять путь закрытия из каждого не-`deleted` и +не-`cancelled` состояния; список загрузок (`/`) ограничиваться действиями над +активными загрузками MAY. Загрузку в `deleted` закрывать система SHALL NOT +(состояние строго терминально). -Команда SHALL **только менять статус** и SHALL NOT производить никаких действий с -файлами или раздачей: система SHALL NOT вызывать qBittorrent (раздача не -снимается, продолжает раздаваться) и SHALL NOT удалять либо создавать хардлинки -под `paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие -библиотечные ссылки сознательно остаются на месте. Синхронный source-preflight -«Закрыть» выполнять SHALL NOT (источник в действии не участвует). +Путей закрытия **два**, и они разведены гейтом терминальности: -Переход SHALL помечаться `error_code = "user_dismiss"` (человекочитаемая причина — -в `error_msg` и логе перехода), отличающим стоп-кран от отклонения на ревью и от -удаления. Новый статус для этого система вводить SHALL NOT — переиспользуется -существующее терминальное `cancelled` (сверка его не переоценивает). Из -`cancelled` пользователю остаётся доступной перепривязка (relink), если он -передумает. +- **«Отменить»/«Отклонить»** (`Cancel`) — отмена **активной** работы. Команда + SHALL быть доступна только из не-терминальных состояний и SHALL отклоняться + с конфликтом из терминальных, включая `cancelled`. +- **«Закрыть»** (`Dismiss`) — стоп-кран для загрузки, которую уже никто не + двигает: зависшей, спорной или лишней (в т.ч. дубля-близнеца в + `target_missing`, чьи файлы разложены другой загрузкой). Команда SHALL быть + доступна из любого состояния, кроме `deleted`, включая терминальные, где + `Cancel` уже отказывает. В `cancelled` `Dismiss` SHALL быть идемпотентным + no-op **без записи состояния** — иначе он подменил бы `error_code` прежнего + закрытия. -В интерфейсе команда SHALL размещаться в отдельной «danger zone» (напр. внизу -страницы загрузки), обособленно от штатных действий. +Раскладка путей по поверхностям нормативной **не является**: поверхность +выбирает путь по состоянию, и требовать от каждой поверхности **оба** пути +система SHALL NOT. Сегодня раскладка такова: страница загрузки веб-UI даёт +«Закрыть» в danger-зоне из терминальных состояний, кроме `deleted` и +`cancelled`, а из не-терминальных закрывает кнопкой «Отменить» (на экране +ревью — «Отклонить»); Telegram даёт «Закрыть» из `failed`, `stuck`, `done`, +`orphaned`, `target_missing`; «Отклонить» — на карточке ревью +(`review`/`deferred`). Кнопка закрытия в Telegram живёт на **карточке** задачи; +уведомления о готовности и о рассинхроне её сегодня не несут, а из `linking` +карточка не даёт действий вовсе. REST закрытие предоставлять MAY (сегодня +отдаёт только `cancel`). + +Ни один путь закрытия SHALL NOT производить действий с файлами или библиотечными +ссылками: система SHALL NOT удалять либо создавать хардлинки под +`paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие библиотечные +ссылки сознательно остаются на месте. qBittorrent закрытие вызывать SHALL NOT — +раздача не снимается и продолжает раздаваться, — с **одним** исключением, и оно +привязано к состоянию, а не к команде: если закрытие **любым** путём увело +задачу из `catched` в окне между нашим `add` и записью перехода, worker убирает +только что добавленный торрент. Уборка нормирована в +`openspec/specs/download-tracking/spec.md`, требование «Добавление пойманной +загрузки в qBittorrent», и её границы задаёт оно — здесь они не пересказываются +и не расширяются. Синхронный source-preflight ни один из путей выполнять SHALL +NOT (источник в действии не участвует). + +`error_code` перехода SHALL **различать** пути закрытия, а не унифицировать их. +`Dismiss` SHALL помечать переход `error_code = "user_dismiss"` +(человекочитаемая причина — в `error_msg`), отличая стоп-кран от отклонения на +ревью, от отмены активной работы и от удаления. Оба пути при этом SHALL замещать +прежнюю диагностику записи: `Dismiss` — на `user_dismiss` с причиной закрытия, +`Cancel` — сегодня на пустые `error_code`/`error_msg`; прежний код состояния +(`stalled` у `stuck`, `qbit_error` у `failed`) из записи пропадает безвозвратно. +Лог перехода закрытия его не дублирует: восстановить причину можно только по +более ранней строке перехода **в** `failed`/`stuck`, пока её держит ретенция +логов. Пустота кода на пути `Cancel` описана как сегодняшнее поведение, а не +закреплена нормативно: собственный непустой код у `Cancel` требованием не +запрещён. + +Новый статус ни для одного из путей система вводить SHALL NOT — +переиспользуется существующее терминальное `cancelled` (сверка его не +переоценивает). Из `cancelled` пользователю остаётся доступной перепривязка +(relink), если он передумает; при этом `Undo` и `Delete` из `cancelled` +доступны SHALL NOT (их пол — `done`/`orphaned`/`target_missing`). Закрытие +разложенной загрузки поэтому оставляет её библиотечные ссылки без штатной +команды снятия, и **перепривязка их не снимает**: `Undo` и `Delete` работают +только с последним батчем раскладки, а повторное применение заводит новый. +Снять ссылки прежнего батча система средствами не даёт. + +В интерфейсе «Закрыть» SHALL размещаться в отдельной «danger zone» (напр. внизу +страницы загрузки), обособленно от штатных действий, и SHALL быть отделена от +одиночного клика **дополнительным шагом**: раскрытием danger-зоны и явным +подтверждением там, где транспорт его поддерживает (диалог веб-UI при доступном +JS, второй шаг клавиатуры в Telegram). «Отменить»/«Отклонить» подтверждения +требовать SHALL NOT: команда отменяет работу, которая ещё идёт. + +Отсюда следует **известное ограничение**: по `error_code` отличима «загрузка +закрыта стоп-краном» от «загрузка отменена активной», но не «пользователь +закрыл активную загрузку» от «пользователь отменил её» — оба пути на +не-терминальном состоянии дают один и тот же пустой код. Хуже того, маркер +сегодня кодирует не намерение, а поверхность: одно и то же состояние `stuck` +закрывается из веб-UI отменой (пустой код), а из Telegram стоп-краном +(`user_dismiss`). Цена — потеря различения в логах и диагностике; поведение и +данные не страдают. Восстановить различение можно, дав `Cancel` собственный +непустой код, — это требованием разрешено и оставлено открытым вопросом, а не +отвергнуто. #### Scenario: Закрытие записи без цели не трогает раздачу @@ -582,13 +643,47 @@ Telegram, опц. REST). Команда SHALL переводить загруз - **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` - **AND** библиотечные хардлинки не удаляются - **AND** раздача в qBittorrent не снимается +- **AND** команды `Undo` и `Delete` на записи становятся недоступны, а + «Привязать заново» остаётся доступной +- **AND** после перепривязки и повторного применения ссылки прежнего батча + остаются в библиотеке — их не снимает ни `Undo`, ни `Delete` -#### Scenario: Закрытие зависшей загрузки +#### Scenario: Стоп-кран замещает прежнюю диагностику состояния -- **GIVEN** загрузка в `stuck` (или `failed`/`deferred`) +- **GIVEN** загрузка в `failed` с `error_code = "qbit_error"` и текстом ошибки + в `error_msg` - **WHEN** пользователь даёт команду «Закрыть» -- **THEN** запись переходит в `cancelled` -- **AND** источник в qBittorrent не трогается +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** прежний код и текст ошибки в записи не сохраняются +- **AND** лог перехода закрытия их не дублирует — причина отказа восстановима + только по более раннему переходу в `failed` + +#### Scenario: Закрытие не-терминальной загрузки из веб-UI идёт отменой + +- **GIVEN** загрузка в не-терминальном состоянии (`downloading`, `recognizing`, + `review`, `deferred`, `stuck`), её страница открыта в веб-UI +- **WHEN** пользователь нажимает «Отменить» (на экране ревью — «Отклонить») +- **THEN** запись переходит в `cancelled` и пропадает из активного списка +- **AND** `error_code` и `error_msg` перехода пусты (маркера `user_dismiss` на + этом пути нет) +- **AND** ни раздача в qBittorrent, ни хардлинки не трогаются + +#### Scenario: Стоп-кран доступен там, где отмена уже отказывает + +- **GIVEN** загрузка в терминальном состоянии, кроме `deleted` и `cancelled` + (`done`, `failed`, `reverted`, `target_missing`, `orphaned`) +- **WHEN** пользователь даёт команду «Закрыть» и подтверждает её +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** команда `Cancel` на том же состоянии отклоняется с конфликтом + +#### Scenario: Один и тот же stuck закрывается разными путями с разных поверхностей + +- **GIVEN** загрузка в `stuck` +- **WHEN** пользователь закрывает её из Telegram кнопкой «Закрыть» +- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` +- **AND** та же загрузка, закрытая со страницы веб-UI кнопкой «Отменить», даёт + `cancelled` с пустым `error_code` — по коду перехода поверхности не + различить от намерения #### Scenario: «Закрыть» недоступна для deleted @@ -596,10 +691,18 @@ Telegram, опц. REST). Команда SHALL переводить загруз - **WHEN** пользователь пытается вызвать «Закрыть» - **THEN** команда недоступна, состояние остаётся `deleted` +#### Scenario: Повторное закрытие не подменяет причину прежнего + +- **GIVEN** загрузка в `cancelled`, закрытая ранее отменой (пустой `error_code`) +- **WHEN** приходит команда «Закрыть» +- **THEN** команда проходит no-op'ом, записи состояния не происходит +- **AND** `error_code` остаётся прежним (пустым), а не подменяется на + `user_dismiss` +- **AND** команда «Отменить» на том же состоянии отклоняется с конфликтом + #### Scenario: Закрытую запись можно привязать заново - **GIVEN** запись, закрытая командой «Закрыть» в `cancelled` - **WHEN** пользователь даёт команду «Привязать заново» - **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink из `cancelled`) -