Files
jellybit/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/proposal.md
T
avandClaude Opus 4.8 1369a9cabe Приём: дедуп по target_missing/orphaned + стоп-кран «Закрыть»
Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в target_missing: дедуп искал только активную задачу,
а target_missing терминален → заводилась новая загрузка, воркер усыновлял
уже присутствующий торрент и раскладывал его.

- Приём: критерий дедупа расширен до «блокирующей повторный приём» =
  активные ∪ {target_missing, orphaned}. Повторный приём такого инфохэша
  привязывается к существующей записи (спящей, без обращения к qBittorrent),
  а не плодит близнеца. Прочие терминальные (done/cancelled/failed/reverted/
  deleted) повторный приём не блокируют — осознанная свежая попытка. Новый
  read-метод FindReingestBlockingByInfohash (приоритет активной над desync);
  общий active-гард не тронут.
- Команда «Закрыть» (Dismiss) — универсальный стоп-кран из любого состояния,
  кроме deleted → cancelled (error_code=user_dismiss). Только меняет статус:
  файлы (в т.ч. хардлинки done/orphaned) и раздачу qBittorrent не трогает,
  в отличие от «Удалить». Веб — danger-зона внизу страницы; Telegram —
  кнопка с подтверждением; из cancelled — идемпотентный no-op.
- Транспорты при дедупе на desync-запись сообщают адресно (target_missing —
  привязать заново/закрыть; orphaned — закрыть и добавить заново); веб при
  дедупе ведёт на страницу существующей записи.

Спеки: ingest (дедуп), state-reconciliation (стоп-кран); граф переходов
допополнен рёбрами <терминал>→cancelled. OpenSpec change
dedup-target-missing-and-dismiss заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:15:37 +03:00

74 lines
6.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Why
При повторном приёме торрента, у которого уже есть запись в `target_missing`
(«разложено, но файлов в библиотеке нет»), рождается загрузка-близнец: дедуп
приёма ищет только **активную** задачу по инфохэшу, а `target_missing`
терминально — активной нет, заводится новая загрузка, воркер усыновляет
присутствующую в qBittorrent раздачу и раскладывает её. В итоге на один торрент
две записи (`done` + `target_missing`), причём у осиротевшей единственное
действие «Привязать заново» упрётся в уже занятый целевой путь. Пользователю
нечем аккуратно закрыть лишнюю запись, не снося при этом раздачу.
## What Changes
- **Предотвращение дубля на приёме.** Повторный приём инфохэша, которым владеет
запись в `target_missing` или `orphaned`, SHALL привязываться к этой записи
(возврат существующей, `Deduplicated`), а не заводить новую загрузку. Критерий
дедупа расширяется с «активной» до «активной **или** удерживающей источник
ради незакрытого намерения» (`target_missing`/`orphaned`). `done` из дедупа
сознательно остаётся размножаемым (повторный приём завершённого = осознанное
«хочу заново»). Приём остаётся быстрым: qBittorrent не трогаем, авто-relink не
запускаем — пользователю сообщается, что запись существует и её нужно привязать
заново.
- **Команда «Закрыть» — универсальный стоп-кран.** Добавляется ручная команда,
доступная из **любого** состояния (кроме `deleted`) во всех транспортах,
переводящая запись в терминальное `cancelled` (с `error_code`-дискриминатором)
и убирающая её из активного списка. Команда **только меняет статус**: файлы под
`paths.*` не трогает (в т.ч. из `done`/`orphaned` библиотечные хардлинки
сознательно остаются на месте) и раздачу в qBittorrent не снимает (в отличие от
«Удалить»). Размещается в отдельной danger zone внизу страницы. Так
пользователь закрывает лишнего близнеца, а заодно получает страховку для любой
зависшей/спорной загрузки.
Явно вне scope: авто-схлопывание дублей в фоновой сверке (выбран ручной путь);
изменение логики усыновления в воркере; введение нового статуса (переиспользуем
`cancelled`, как Delete переиспользует `deleted`); удаление/создание каких-либо
файлов или раздач командой «Закрыть».
## Capabilities
### New Capabilities
<!-- нет новых capability -->
### Modified Capabilities
- `ingest`: критерий дедупликации приёма расширяется — блокирующими повторный
приём становятся не только активные, но и `target_missing`/`orphaned` записи
(attach вместо создания новой); повторный приём завершённой (`done`) остаётся
созданием новой. Модифицируются требования «Дедупликация приёма по любому из
хешей» и «Приём источника и заведение загрузки» (терминология «блокирующей»
задачи). Требование «Приём из .torrent-файла» текст НЕ правит: оно уже явно
делегирует критерий модифицированному требованию через inline-ссылку — оба
дедуп-упоминания там наследуют расширенный критерий без риска дрейфа.
- `state-reconciliation`: добавляется пользовательская команда «Закрыть»
(dismiss) из любого состояния (кроме `deleted`) в терминальное `cancelled`,
ничего не делающая с файлами и раздачей; фиксируется её отличие от «Удалить» и
новые рёбра перехода `<любое> → cancelled` (в т.ч. из терминальных
`done`/`failed`/`reverted`/`target_missing`/`orphaned`).
## Impact
- Код: `internal/ingest/ingest.go` (`Ingest`/`attached` — ветка attach для
desync-записей, флаг «нужен relink»), `internal/store/download.go`
(критерий поиска дедупа: reingest-blocking states = active
`{target_missing, orphaned}`; новое ребро `allowedTransitions`
`target_missing → cancelled`; `error_code`-дискриминатор dismiss),
`internal/worker/review.go` (обработчик команды «Закрыть» рядом с
Delete/Undo/Relink — только setState, без файлов и qBittorrent), веб-UI
(danger zone внизу страницы с кнопкой «Закрыть»), `internal/tgbot`.
- Данные: новых таблиц/столбцов нет; терминальный набор не меняется (dismiss →
существующее `cancelled`). Обновляется граф переходов и, при необходимости,
описание статусов в `docs/specs/database.md`.
- Инварианты безопасности данных: «Закрыть» источник неприкосновенен —
qBittorrent не вызывается, файлы под `paths.downloads`/`movies`/`series` не
трогаются.