закрыта задача bulk-delete-page, заведены три задачи из урожая ревью

This commit is contained in:
av
2026-08-10 17:51:59 +03:00
parent 288be8ec34
commit b939192348
5 changed files with 191 additions and 65 deletions
+3 -1
View File
@@ -27,7 +27,8 @@
- [✨ Переделать веб-UI в устанавливаемое PWA](items/web-ui-pwa.md) — текущий server-rendered UI функционален — PWA (устанавливаемое, удобное с телефона) это улучшение большого объёма, не блокер
- [✨ Править на ревью маппинг «файл → серия» и раскладывать вручную при провале LLM](items/review-mapping-editor.md) — правка S·E, «нумеровать подряд» и ручной режим при полном провале LLM были запланированы объёмом Ф5 и не заведены задачей — в ревью сегодня можно только подсказать текстом
- [✨ Заказать спекой крайние случаи именования: многофайловый фильм, редакции, двойная серия](items/naming-edge-cases.md) — стэкинг частей (part1/cd1), редакции [edition-…] и двойная серия SxxEyy-Eyy описаны нарративом, но в file-layout не заказаны — раскладка таких раздач не определена
- [✨ Удалять выбранные загрузки с файлами на отдельной странице](items/bulk-delete-page.md) — удаление с файлами живёт только в danger-секции страницы одной загрузки: чтобы снести десять раздач, надо десять раз пройти путь список → карточка → подтверждение
- [🐞 Не сносить раздачу, которой владеет другая активная загрузка](items/delete-checks-active-infohash-owner.md) — удаление старой закрытой задачи уничтожает файлы живой загрузки с тем же инфохэшем; воспроизведено падающим тестом на ревью bulk-delete-page
- [🐞 Не оставлять задачу в done, когда ссылки сняты, а раздача не снесена](items/delete-leaves-stale-done.md) — при недоступном qBittorrent задача весь простой соседа показывает done, хотя тайтла в Jellyfin уже нет: сверка падает на первом шаге и до коррекции не доходит
- [🔬 Канон нумерации серий и порядок у провайдера тега](items/episode-numbering-canon.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
- [🔬 Тексты и формат уведомлений в Telegram](items/telegram-messages-audit.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
- [🔬 guessit как сервис-спутник](items/guessit-sidecar.md) — go-ptn слабее питоновского guessit — если точности пред-парса не хватит, завернуть guessit в сервис-спутник рядом с бинарём
@@ -48,6 +49,7 @@
- [✨ Чистить БД от терминальных задач и сырых ответов LLM старше срока хранения](items/db-retention-cleanup.md) — терминальные задачи и сырые ответы LLM копятся вечно — без авточистки список загрузок и БД деградируют по мере эксплуатации
- [🧹 Свести термины домена в словарь единого языка](items/ubiquitous-language-glossary.md) — наименования домена расходятся между спеками, UI и кодом — нет единого глоссария (на нём же стоит агент-ревьювер наименований)
- [🧹 Разобрать кандидатов по тестам и записать конвенцию](items/tests-convention.md) — как пишем тесты, не записано нигде: пункт «Тесты» в convention-candidates не пересматривали, он обещает фикстуры в testdata/, которых в проекте нет — а трение накопилось (четыре внешних клиента, fakeStore с инъекцией ошибок, env-гейты, флаки-прогон, diff-coverage)
- [🐞 Не держать общий замок воркера во время обращения к qBittorrent при удалении](items/delete-holds-worker-lock.md) — на время удаления встаёт весь фон: опрос, сверка и команды остальных транспортов ждут замок до 30 с на загрузку, пачкой — минутами; замерено на ревью bulk-delete-page
- [🔬 Потолок нагрузки: 100 одновременных загрузок, план-максимум 1000](items/scale-100-downloads.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
- [🔬 Завершение загрузки через webhook](items/completion-webhook.md) — завершение сейчас ловим поллингом qBittorrent — webhook реагировал бы быстрее, но связывает нас с его конфигом (решим по опыту эксплуатации)
- [🔬 Кандидаты в конвенции кода](items/convention-candidates.md) — накоплен список кандидатов (внешние клиенты, конкурентность, тесты, CLI, время) — надо решить, что из них стало реальным трением, а что выдумано вперёд
-64
View File
@@ -1,64 +0,0 @@
# ✨ Удалять выбранные загрузки с файлами на отдельной странице
- **Тип:** feature
- **Категория:** Ядро продукта
- **Зачем:** удаление с файлами живёт только в danger-секции страницы одной загрузки: чтобы снести десять раздач, надо десять раз пройти путь список → карточка → подтверждение
- **Теги:** goal:bulk-download-management
Отдельная страница веб-UI, доступная из шапки: список загрузок, у которых
удаление разрешено (`done`, `orphaned`, `target_missing`
`internal/worker/review.go:631`), чекбокс у каждой строки и одна кнопка
«Удалить выбранные». Кнопка ведёт на подтверждение, где выбранные раздачи
названы поимённо, и только оттуда уходит удаление.
Страница отдельная, чтобы не вешать режим выбора на основной список: там
карточки живые, самополлятся и перерисовываются, а выбор пользователя такое
перерисовывание переживать не обязан.
Речь только о полном удалении (снять хардлинки + снести раздачу с файлами из
qBittorrent). Групповой `Dismiss` (смена статуса без файлов) в эту задачу не
входит.
## Затрагивает
- новая страница веб-UI и ссылка на неё в `web/templates/partials/header.html`;
новый шаблон страницы и её партиалы;
- новый POST-эндпоинт группового удаления в `internal/httpapi` (рядом с
`POST /ui/downloads/{id}/delete`, `internal/httpapi/httpapi.go:143`);
- `Reviewer.Delete` воркера (`internal/worker/review.go:619`) — зовётся по
каждой выбранной загрузке;
- qBittorrent `torrents/delete` с `deleteFiles=true` — внешний сервис,
необратимая операция;
- спека `web-ui`, требования «Страницы веб-UI» и «Действия соответствуют
состоянию»;
- спека `state-reconciliation` в части `Delete` — групповой вызов не меняет
условий поштучного, но подтверждение человека теперь одно на пачку.
## Критерии приёмки
- Страница открывается из шапки и показывает только те загрузки, для которых
удаление разрешено поштучно (оракул: тест `internal/httpapi` — подставной
читатель отдаёт задачи во всех состояниях, в разметке строки есть у
`done`/`orphaned`/`target_missing` и нет у остальных).
- Удаление уходит только после явного подтверждения, и подтверждение называет
каждую выбранную раздачу поимённо (оракул: тест — POST без признака
подтверждения отвечает отказом и не делает ни одного вызова `Delete` у
подставного воркера; ответ подтверждения содержит заголовки всех выбранных).
- Отказ на одной загрузке не отменяет остальных, а результат называет
удалённые и отказавшие поимённо с причиной (оракул: тест, где второй `Delete`
возвращает ошибку — первая и третья удалены, страница результата называет
вторую и её причину).
- Групповой путь не расширяет прав поштучного: попытка удалить задачу в
состоянии, где кнопка недоступна, отклоняется с тем же отказом
(оракул: тест — `Delete` для `downloading` возвращает `ErrConflict`, страница
показывает отказ, остальные выбранные не затронуты).
- Поведение страницы записано дельта-спекой и проходит валидацию (оракул:
`openspec validate --strict` и `task gate`).
## Рамки
Необратимое действие: `deleteFiles=true` сносит файлы раздачи, гард последней
копии в `Delete` выключен сознательно. Ослаблять подтверждение ради удобства
пачки нельзя — оно остаётся обязательным и поимённым. Групповой `Dismiss` и
автоматическая чистка по сроку хранения (`db-retention-cleanup`) — не эта
задача.
@@ -0,0 +1,66 @@
# 🐞 Не сносить раздачу, которой владеет другая активная загрузка
- **Тип:** fix
- **Категория:** Ядро продукта
- **Зачем:** удаление старой закрытой задачи уничтожает файлы живой загрузки с тем же инфохэшем; воспроизведено падающим тестом на ревью bulk-delete-page
- **Теги:** goal:state-integrity
`Delete` зовёт `torrents/delete` с `deleteFiles=true` по хешам своей записи и не
спрашивает, не владеет ли этим инфохэшем другая **активная** загрузка. Человек
подтверждает удаление одной записи, а необратимое действие применяется к чужим
живым данным.
Двигает у цели `state-integrity` строку завершения «ни один известный сегодня
путь не оставляет состояние, которое не объясняется историей переходов»: живая
загрузка B уходит в `failed` по пропаже источника, и по её записи не видно, что
файлы снесла чужая операция.
## Воспроизведение
1. Довести задачу A по инфохэшу H до `done`.
2. Добавить тот же релиз заново: `CreateDownloadIfNoActive` ищет владельца
только среди активных, A терминальна, дедупа нет — заводится активная задача
B по тому же H. Файлы на диске у A и B общие, qBittorrent дедуплицирует
раздачу по хешу.
3. Удалить A — со страницы загрузки, из Telegram или со страницы группового
удаления.
Видно вместо ожидаемого: раздача снесена с файлами, B продолжает считать себя
качающейся и уходит в `failed` после дебаунса. Если B ещё не дошла до раскладки,
копии не остаётся нигде.
Прогнанные оракулы ревью (отчёт —
`openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md`, находка
2): `TestAdversaryDeletablePageOffersHashOwnedByActiveDownload`,
`TestAdversaryDeleteWipesSourceOfAnotherActiveDownload`.
## Затрагивает
- `Worker.Delete` (`internal/worker/review.go`) — вызов `qbt.Delete`;
- предикат `store.FindActiveByInfohash` — существующий, новый заводить не надо;
- спека `state-reconciliation`, требование «Полное удаление загрузки
пользователем» — сегодня оно про такую проверку молчит;
- строка выбора и подтверждения на странице группового удаления, если решено
показывать признак чужого владения;
- qBittorrent `torrents/delete` с `deleteFiles=true` — необратимая операция.
## Критерии приёмки
- Удаление задачи, чьим инфохэшем владеет активная загрузка, отклоняется
конфликтом и раздачу не сносит (оракул: тест `internal/worker` — задача A в
`done` и активная B по тому же хешу, `Delete(A)` возвращает `ErrConflict`, у
подставного клиента qBittorrent ноль вызовов `Delete`).
- Отказ виден человеку причиной, а не общим «внутренняя ошибка» (оракул: тест
`internal/httpapi` — строка отчёта называет, что инфохэшем владеет активная
задача).
- Удаление задачи, чей инфохэш никем больше не занят, работает как прежде
(оракул: существующие тесты `Delete` остаются зелёными).
- Поведение записано дельта-спекой `state-reconciliation` (оракул:
`openspec validate --strict` и `task gate`).
## Рамки
Необратимое: снос раздачи с файлами. Проверка ставится в ядре, а не в
транспорте — допуск обязан держаться без транспорта. Правило дедупа при приёме
(«владелец ищется среди активных») эта задача не меняет: связывание v1/v2 и
доверие к паре `xt` разбирает `infohash-identity-integrity`.
+59
View File
@@ -0,0 +1,59 @@
# 🐞 Не держать общий замок воркера во время обращения к qBittorrent при удалении
- **Тип:** fix
- **Категория:** Инфраструктура
- **Зачем:** на время удаления встаёт весь фон: опрос, сверка и команды остальных транспортов ждут замок до 30 с на загрузку, пачкой — минутами; замерено на ревью bulk-delete-page
`Worker.Delete` берёт `w.mu` и держит его через сетевой вызов `qbt.Delete`, хотя
правило пакета обратное и записано рядом: медленные вызовы идут **вне** замка
(`internal/worker/worker.go`, `Poll` зовёт `qbt.Torrents` до `Lock`). Замок при
этом один на весь воркер, а не на задачу — комментарий пакета и
`docs/architecture.md` называют его per-download, и это тоже неверно.
## Воспроизведение
1. Заставить qBittorrent отвечать на `torrents/delete` медленно (подставной
клиент с задержкой; в жизни — снос раздачи с большим числом файлов на
загруженном диске).
2. Во время вызова дёрнуть через тот же воркер любую команду по **другой**
загрузке.
Видно вместо ожидаемого: команда ждёт весь чужой сетевой вызов. Замер ревью: при
задержке `qbt.Delete` 300 мс конкурентный `Cancel` по неизвестному
идентификатору заблокирован на `280.450631ms`. Таймаут клиента qBittorrent —
зашитые 30 с (поля в `[qbittorrent]` нет), поэтому верхняя граница на одну
загрузку — 30 с, а на пачку группового удаления — минуты, в течение которых не
идут ни опрос, ни сверка, ни уведомления.
Отчёт ревью с оракулами —
`openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md`,
находка 3.
## Затрагивает
- `Worker.Delete` (`internal/worker/review.go`) — границы критической секции;
- `w.mu` и комментарий пакета `internal/worker` — «per-download» неверно;
- `docs/architecture.md`, строка «Переходы состояний» в единых точках — тот же
неверный факт;
- потолок времени на проход группового удаления (`httpapi.bulkBudget`) и
требование о нём в спеке `web-ui` — при сужении замка они теряют основание;
- поле таймаута в секции `[qbittorrent]` конфига, которого сегодня нет.
## Критерии приёмки
- Команда по одной загрузке не ждёт сетевого вызова по другой (оракул: тест
`internal/worker` с подставным клиентом, блокирующимся внутри `Delete`, —
`w.mu.TryLock()` из теста возвращает `true`).
- Состояние после сетевого вызова перечитывается, и удаление не переходит в
`deleted`, если задача успела уйти из допустимого состояния (оракул: тест на
смену состояния между шагами).
- Факт про замок исправлен в комментарии пакета и в `docs/architecture.md`
(оракул: `task gate`, шаг `canon`).
- Решено и записано, остаётся ли потолок времени на проход группового удаления
(оракул: дельта-спека и `openspec validate --strict`).
## Рамки
Трогает операцию, которая сносит файлы: разбор идемпотентности при повторе
обязателен. Уборка собственного торрента после отмены (`processCatched`) — уже
устроена нужным образом и служит образцом, менять её не надо.
+63
View File
@@ -0,0 +1,63 @@
# 🐞 Не оставлять задачу в done, когда ссылки сняты, а раздача не снесена
- **Тип:** fix
- **Категория:** Ядро продукта
- **Зачем:** при недоступном qBittorrent задача весь простой соседа показывает done, хотя тайтла в Jellyfin уже нет: сверка падает на первом шаге и до коррекции не доходит
- **Теги:** goal:state-integrity
`Delete` снимает библиотечные ссылки раньше, чем зовёт `qbt.Delete`. Если сосед
недоступен, локальный шаг проходит, внешний падает, и задача остаётся в `done`.
Спека рассчитывает, что расхождение живёт «до тика сверки», но `Poll`
возвращается на первой же ошибке `qbt.Torrents` и до `reconcileDesync` не
доходит вовсе — значит, расхождение живёт весь простой qBittorrent.
Двигает у цели `state-integrity` ту же строку завершения — «ни один известный
сегодня путь не оставляет состояние, которое не объясняется историей
переходов»: `done` держится сколь угодно долго и историей переходов не
объясняется.
## Воспроизведение
1. Довести задачу до `done` с существующими библиотечными ссылками.
2. Погасить qBittorrent (в тесте — клиент на несуществующий адрес).
3. Выполнить удаление — поштучно или пачкой.
Видно вместо ожидаемого: ссылки сняты, строки `file_link` удалены, раздача цела,
место не освобождено, а карточка, список и Telegram показывают `done`. Пока
qBittorrent лежит, ни один тик сверки этого не исправит. Замер ревью: отказ
приходит за `524.545µs`, то есть пачка успевает пройти по всем выбранным раньше,
чем человек поймёт, что сосед недоступен.
Отчёт ревью —
`openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md`,
находка 4.
## Затрагивает
- `Worker.Delete` (`internal/worker/review.go`) — порядок шагов и переход после
отказа внешнего шага;
- `Worker.Poll` (`internal/worker/worker.go`) — ранний возврат по отказу
`qbt.Torrents` перед `reconcileDesync`;
- спека `state-reconciliation`, требование «Полное удаление загрузки
пользователем» — формулировка «кратковременное рассогласование до тика сверки
ожидаемо» сегодня неверна;
- граф переходов: `done → target_missing` уже легален, нового состояния не надо.
## Критерии приёмки
- После отказа внешнего шага при снятых ссылках задача не остаётся в `done`
(оракул: тест `internal/worker` — клиент qBittorrent отказывает, задача
оказывается в `target_missing`, а не в `done`).
- В `deleted` при этом задача не переходит: место не освобождено (оракул: тот же
тест).
- Повторное удаление после подъёма соседа идемпотентно дожимает раздачу (оракул:
тест — второй вызов при живом клиенте уводит задачу в `deleted`).
- Формулировка спеки приведена к тому, что код делает (оракул:
`openspec validate --strict` и `task gate`).
## Рамки
Порядок шагов менять можно, но снос раздачи раньше снятия ссылок — отдельное
решение с ценой: оно убирает необратимый локальный шаг при мёртвом соседе, но
меняет поведение при отказе на втором шаге. Ранний возврат `Poll` по отказу
`qbt.Torrents` трогать осторожно: на нём стоит вся сверка.