Приём: дедуп по 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>
This commit is contained in:
av
2026-07-10 20:15:37 +03:00
co-authored by Claude Opus 4.8
parent b8657120fe
commit 1369a9cabe
26 changed files with 1157 additions and 68 deletions
+19 -3
View File
@@ -160,6 +160,8 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
row := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
// Стоп-кран: закрыть зависшую задачу, не трогая файлы/раздачу.
tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id),
}
if url := b.reviewURL(id); url != "" {
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
@@ -169,14 +171,18 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
}
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Удалить». Само удаление
// двухшаговое — кнопка ведёт на подтверждение (deleteConfirmKeyboard).
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
// ведёт на подтверждение.
func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
var row []tgbotapi.InlineKeyboardButton
if url := b.reviewURL(id); url != "" {
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
}
row = append(row, tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id))
row = append(row,
tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id),
tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id),
)
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
return &kb
}
@@ -191,6 +197,16 @@ func (b *Bot) deleteConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
return &kb
}
// dismissConfirmKeyboard — шаг подтверждения закрытия (стоп-кран): перевод в
// «отменено» без действий над файлами/раздачей. Явное «Да» отделено от отмены.
func (b *Bot) dismissConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("✖️ Да, закрыть", "dismiss_confirm:"+id),
tgbotapi.NewInlineKeyboardButtonData("Отмена", "dismiss_cancel:"+id),
))
return &kb
}
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
url := b.reviewURL(id)
if url == "" {