Files
jellybit/docs/specs/workflow.md
T
avandClaude Opus 4.8 8261d5b55d Retry/stall: сброс базиса таймаута + простой от last_activity (MAJOR-1, MAJOR-2)
Два связанных бага семантики таймаутов зависания и ручного retry.

MAJOR-1: Retry живого торрента не сбрасывал базис отсчёта таймаута — задача
мгновенно снова падала в stuck на ближайшем тике. Вводим колонку
download.retried_at (миграция 0010): ручной retry фиксирует момент и
приподнимает пол обоих таймаутов (max(базис, retried_at)). Хранится в БД, а
не в памяти, чтобы сброс пережил тик поллинга и рестарт.

MAJOR-2: stuck_after мерил ВОЗРАСТ торрента (от added_on), а не ПРОСТОЙ —
долго качавшийся торрент, на миг зашедший в stalledDL, ложно уходил в stuck
со «stalled for 5h». Теперь stuck_after мерит простой от qBit last_activity
(новое поле qbt.Torrent из того же ответа /torrents/info); magnet_timeout
по-прежнему мерит возраст (семантически верно). checkTimeouts разбит на
torrentAge/stallDuration/addedBasis/retriedFloor.

NIT-10: фолбэк базиса возраста added_on→created_at сохранён и покрыт.
NIT-12: retry перестаёт перецепляться к сломанному живому торренту
(error/missingFiles) — повторно отдаёт источник (перецепка к нему
бессмысленна: reconcile тут же вернул бы в failed).

Спека: дельта state-reconciliation (MODIFIED «Восстановление зависшей
загрузки» и «Ручной повтор»), правка docs/specs/workflow.md (устранено
противоречие «возраст vs простой»), ER-схема database.md.

Тесты: TestRetryResetsTimeoutBasis (следующий тик после retry — прячется в
TestRetryReattachesNoReadd), TestStallMeasuredFromLastActivity,
TestSetRetriedAtOverwrites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:18:28 +03:00

15 KiB
Raw Blame History

Жизненный цикл загрузки и машина состояний

Источник истины переехал в OpenSpec. Прямой путь FSM (downloading → completed → stuck/failed, поллинг, усыновление) — openspec/specs/ download-tracking/; сверка с реальностью — openspec/specs/ state-reconciliation/; уведомления — openspec/specs/notifications/. Этот файл — справочный нарратив по графу состояний; при расхождении верна спека OpenSpec.

Как загрузка проходит путь от приёма источника до разложенных файлов: состояния, переходы и то, что их вызывает. Кто владеет переходами и общее устройство — в architecture.md; детали распознавания — в recognition.md; действия человека в ревью — в review-ux.md.

Граф состояний

stateDiagram-v2
    [*] --> downloading: ingest (источник отдан в qBittorrent)

    downloading --> completed: файлы на месте
    downloading --> stuck: stalledDL дольше stuck_after
    downloading --> failed: metaDL дольше magnet_timeout (страховка) / error

    completed --> recognizing

    recognizing --> linking: авто (матч в базе + валидация)
    recognizing --> review: нужно подтверждение / ответ LLM не разобран

    review --> linking: Применить
    review --> recognizing: Уточнить / Распознать заново
    review --> deferred: Позже
    review --> cancelled: Отклонить
    deferred --> review: любое действие (та же поверхность)

    linking --> done
    linking --> review: коллизия цели
    linking --> failed: ошибка ФС

    done --> reverted: Undo

    reverted --> recognizing: Привязать заново
    cancelled --> recognizing: Привязать заново

    stuck --> downloading: Retry / сверка (раздача ожила)
    failed --> downloading: Retry / сверка (метаданные пришли)
    failed --> completed: сверка (торрент уже готов)
    stuck --> completed: сверка (торрент уже готов)

    done --> target_missing: сверка — цель удалена
    done --> orphaned: сверка — источник пропал
    target_missing --> recognizing: Привязать заново
    target_missing --> orphaned: источник тоже пропал
    target_missing --> deleted: источник тоже пропал
    orphaned --> deleted: цель тоже удалена
    target_missing --> done: healing (цель вернулась)
    orphaned --> done: healing (источник вернулся)

    done --> [*]
    cancelled --> [*]
    reverted --> [*]
    deleted --> [*]

    note right of cancelled
        «Отклонить» доступно из любого
        нетерминального состояния
    end note

Условно-терминальные состояния — done, cancelled, failed, reverted: задача в них останавливается, но из failed/stuck есть Retry, а из reverted/cancelledПривязать заново. stuck восстановимо ретраем.

Состояния и переходы

  • ingest → downloading — приняли источник + контекст, отдали в qBittorrent (категория qbittorrent.category), записали в БД с ключом идемпотентности. См. architecture.md → «Транспорты».
  • downloading / completedworker поллит qBittorrent (worker.poll_interval, 5 с). Готовность — только когда файлы на месте (не moving/checking*), см. «Завершение в qBittorrent» ниже.
  • recognizingrecognize строит план и оценку уверенности (recognition.md). Невалидный/непарсящийся ответ LLM → review (не failed).
  • review — план уходит человеку (review-ux.md); цикл review ⇄ recognizing — перераспознавание по подсказке. «Уточнить» — подсказка + перераспознавание; «Распознать заново» — повторный прогон без новой подсказки, по уже накопленному контексту и подсказкам.
  • deferred — «Позже» паркует задачу; принимает те же команды, что и review, и возвращается в поверхность ревью по любому действию.
  • linkinglayout создаёт хардлинки; идемпотентно, батчем. Коллизия цели возвращает в review, ошибка ФС → failed. См. architecture.md → «Раскладка файлов».
  • done — при входе неблокирующе дёргаем пересканирование Jellyfin (опц., см. architecture.md → «Пересканирование Jellyfin»); доступен Undoreverted (убрать созданные ссылки).
  • stuck / failed / cancelled — не качается дольше таймаута; ошибка (ретраибельна); «Отклонить».
  • reverted / cancelled → recognizing — «Привязать заново»: после отката или отклонения можно перезапустить распознавание для той же раздачи. Перепривязка всегда идёт через review с ручным подтверждением (авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в qBittorrent.

Сверка с реальностью (рассинхрон)

Состояние в БД может разойтись с диском при ручном удалении: раздачу стирают из qBittorrent (источник) или файлы убирают из Jellyfin (целевые хардлинки). worker периодически сверяет уже разложенные задачи с фактом по двумерной матрице «источник × цель» (источник = раздача в qBittorrent, цель = разложенные хардлинки на ФС) и выводит состояние:

  • target_missing — источник на месте, цель удалена. Доступна команда «Привязать заново» (→ recognizing); авто-действий нет.
  • orphaned — источник пропал, цель (последняя копия данных) на месте. Команд вперёд нет; Undo запрещён (снял бы единственную копию).
  • deleted — нет ни источника, ни цели; терминально: сверка его больше не переоценивает (см. ниже).

Сверка трогает только done/target_missing/orphaned — терминальный deleted, активные и пользовательски-терминальные (reverted/cancelled/ failed/stuck) состояния не задевает. Реальность «лечится» сама: при возврате источника/цели задача переходит обратно (вплоть до done) — но не из deleted: к терминальной задаче источник не вернётся (идемпотентность снимается только для активных), а её бывший целевой путь, если его заняла другая загрузка, отбирается переходом владения (см. jellyfin-layout.md → «Владение целевым путём»). Без этого правила переиспользование пути ложно «воскрешало» бы удалённую задачу в orphaned. Пропажа источника дебаунсится ([worker].source_missing_threshold подряд идущих тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды, которым нужен источник (relink/распознать/применить/undo), проверяют его синхронно перед действием и не полагаются на фоновую сверку. Полные требования — openspec/specs/state-reconciliation/.

Все переходы и команды идут через worker под per-download блокировкой — два транспорта не гонятся за одно состояние. Состояние персистентно в SQLite; worker периодически сверяет qBittorrent с БД и усыновляет раздачи с нашей категорией (qbittorrent.category) или тегом (qbittorrent.tag), которых ещё нет в БД, заводя для них задачу в состоянии downloading. Категория ставится на добавляемые нами раздачи (push, задаёт savepath); тег позволяет подхватить уже существующую раздачу, не трогая её категорию и файлы (pull).

Завершение в qBittorrent

worker опрашивает qBittorrent и сопоставляет его состояния с нашими:

  • готово к раскладке: uploading/stalledUP/pausedUP/stoppedUP/ queuedUP/forcedUP (имена paused*/stopped* различаются между qBit v4 и v5 — поддержаны оба).
  • переходное, ждём: moving/checkingUP/checkingResumeData/ allocating — остаёмся в downloading, пока qBit не закончит перенос/ проверку (готовность не объявляем, даже если флаги «UP»).
  • ещё качается: downloading/stalledDL/metaDL/forcedMetaDL/ queuedDL/checkingDL/forcedDL/pausedDL/stoppedDL.
  • застряло по таймауту (страховка): metaDL/forcedMetaDL дольше magnet_timeoutfailed; stalledDL простаивающий дольше stuck_afterstuck. magnet_timeoutредкий страховочный предохранитель (дефолт 24h), а не рабочий механизм: долгий metaDL (медленные трекеры/мало пиров) — это норма, его не убиваем агрессивно. Меры у двух таймаутов разные: magnet_timeout мерит возраст торрента от добавления в qBittorrent (added_on, фолбэк created_at); stuck_after мерит длительность простоя — от last_activity (последнее движение данных), а не возраст, иначе долго качавшийся торрент, на миг зашедший в stalledDL, ложно уходит в stuck со «stalled for 5h». Оба базиса приподнимаются до retried_at — ручной retry сбрасывает отсчёт, чтобы возврат в downloading не ронял задачу снова на ближайшем тике.
  • ошибка: error/missingFilesfailed (error_code qbit_error) — это настоящий провал, в отличие от таймаута.

Уведомление и восстановление

  • Любой переход в failed/stuck уведомляет автора загрузки (notifier), чтобы падение не оставалось незамеченным — включая приёмное падение qbit_add (не удалось добавить в qBittorrent), которое идёт мимо поллинг-цикла. Повторные падения одной задачи в пределах окна дебаунса уведомляют лишь раз — чтобы мерцающий stalled-торрент (stuckdownloading) не спамил.
  • failed/stuck из-за нашей нетерпеливости (error_code magnet_timeout/ stalled) не тупик: фоновая сверка возвращает задачу в поток, как только источник в qBittorrent ожил и продвинулся за условие падения (получил метаданные → downloading; уже готов → completed). Пока торрент всё ещё в metaDL/stalledDL, задача остаётся упавшей (без зацикливания). Настоящие провалы (qbit_error) сверкой не воскрешаются.
  • Дополнительно доступен ручной retry из веб-UI и Telegram (не только REST): возвращает в downloading, перецепляясь к живому здоровому торренту без повторного Add (к сломанному — error/missingFiles — не перецепляемся, повторно отдаём источник) и сбрасывая базис таймаутов (retried_at), чтобы задача не упала снова на ближайшем тике.

Пути файлов берём из API (save_path + относительные имена из /torrents/files, уже включающие корневую папку торрента), не из константы (обычно это уже хост-путь). «Incomplete»-каталог в qBittorrent включён (/srv/media/incomplete): пока качается — файлы там, по завершении qBit переносит их в /srv/media/downloads (состояние moving — дожидаемся окончания переноса и только потом берём финальный путь). Подробнее о путях и песочнице — architecture.md → «Пути и контейнеры».