Привёл набор capabilities в OpenSpec к цепочке обработки, чтобы имя capability отвечало одному поведению. Чисто по спекам, код и поведение системы не меняются. Change refactor-capability-boundaries (архивирован): - recognition разделён на recognition (разбор LLM) + metadata-match (сверка с базами) - review выделен из web-ui + мигрирован из docs/specs/review-ux.md - новые capability из docs/specs: file-layout, download-tracking, notifications - identity очищен до инфра-id; приём (инфохэши, дедуп, ядро приёма) — в ingest - уведомление о рассинхроне перенесено из state-reconciliation в notifications - дубль владения путём и безопасного undo оставлен в state-reconciliation Итог: 11 capabilities, openspec validate --strict проходит (+37/−11 требований). Источник истины по мигрированным темам переехал в openspec/specs (шапки в docs). Снят пункт беклога «Пересмотр набора capabilities». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
Жизненный цикл загрузки и машина состояний
Источник истины переехал в 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 / completed —
workerполлит qBittorrent (worker.poll_interval, 5 с). Готовность — только когда файлы на месте (неmoving/checking*), см. «Завершение в qBittorrent» ниже. - recognizing —
recognizeстроит план и оценку уверенности (recognition.md). Невалидный/непарсящийся ответ LLM → review (не failed). - review — план уходит человеку (review-ux.md); цикл
review ⇄ recognizing— перераспознавание по подсказке. «Уточнить» — подсказка + перераспознавание; «Распознать заново» — повторный прогон без новой подсказки, по уже накопленному контексту и подсказкам. - deferred — «Позже» паркует задачу; принимает те же команды, что и
review, и возвращается в поверхность ревью по любому действию. - linking —
layoutсоздаёт хардлинки; идемпотентно, батчем. Коллизия цели возвращает в review, ошибка ФС → failed. См. architecture.md → «Раскладка файлов». - done — при входе неблокирующе дёргаем пересканирование Jellyfin
(опц., см. architecture.md → «Пересканирование
Jellyfin»); доступен Undo →
reverted(убрать созданные ссылки). - 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_timeout→failed;stalledDLдольшеstuck_after→stuck.magnet_timeout— редкий страховочный предохранитель (дефолт24h), а не рабочий механизм: долгийmetaDL(медленные трекеры/мало пиров) — это норма, его не убиваем агрессивно. Возраст считаем от времени добавления торрента в qBittorrent (added_on), а не от создания задачи (базис переживает retry и усыновление). - ошибка:
error/missingFiles→failed(error_codeqbit_error) — это настоящий провал, в отличие от таймаута.
Уведомление и восстановление
- Любой переход в
failed/stuckуведомляет автора загрузки (notifier), чтобы падение не оставалось незамеченным — включая приёмное падениеqbit_add(не удалось добавить в qBittorrent), которое идёт мимо поллинг-цикла. Повторные падения одной задачи в пределах окна дебаунса уведомляют лишь раз — чтобы мерцающийstalled-торрент (stuck↔downloading) не спамил. failed/stuckиз-за нашей нетерпеливости (error_codemagnet_timeout/stalled) не тупик: фоновая сверка возвращает задачу в поток, как только источник в qBittorrent ожил и продвинулся за условие падения (получил метаданные →downloading; уже готов →completed). Пока торрент всё ещё вmetaDL/stalledDL, задача остаётся упавшей (без зацикливания). Настоящие провалы (qbit_error) сверкой не воскрешаются.- Дополнительно доступен ручной retry из веб-UI и Telegram (не только
REST): возвращает в
downloading, перецепляясь к живому торренту без повторногоAdd.
Пути файлов берём из API (save_path + относительные имена из
/torrents/files, уже включающие корневую папку торрента), не из
константы (обычно это уже хост-путь). «Incomplete»-каталог в
qBittorrent включён (/srv/media/incomplete): пока качается — файлы
там, по завершении qBit переносит их в /srv/media/downloads (состояние
moving — дожидаемся окончания переноса и только потом берём финальный
путь). Подробнее о путях и песочнице — architecture.md
→ «Пути и контейнеры».