## Why Пришло время удалять просмотренные фильмы/сериалы, чтобы освобождать место под новые. Удаляют их **вручную** — из qBittorrent (источник) или из Jellyfin (целевые хардлинки). Сейчас jellybit этого не замечает: `worker` поллит только задачи в `downloading`, терминальные задачи (`done`) с реальностью не сверяет. Состояние в БД молча расходится с диском: - **Источник пропал, цель осталась.** qBittorrent стирает скачанные файлы при удалении раздачи. Хардлинк в библиотеке становится **последней** ссылкой на inode, а обычный `Undo` (`unlink` цели) сотрёт единственную копию — прямая потеря данных. Инвариант «источник неприкосновенен» молчаливо перестаёт держаться: источника уже нет. - **Цель пропала, источник остался.** Файлы убрали из библиотеки, а jellybit по-прежнему числит загрузку `done` — состояние врёт. Цель этого change — **path 1**: научить jellybit корректно **распознавать** ручное удаление и **отражать** его в состоянии, **не предпринимая автоматических действий**. Удаление средствами самого jellybit («единое окно», path 2) — отдельная будущая работа. ## What Changes - **Фоновая сверка с реальностью.** `worker` расширяет периодический поллинг: помимо `downloading` сверяет терминальные/desync-задачи с фактом на ФС — присутствие раздачи в qBittorrent (источник) и существование разложенных хардлинков `file_link.dst_path` (цель). - **Новые состояния FSM** для рассинхрона (двумерная матрица «источник × цель»): - `target_missing` — источник на месте, цель удалена: доступен пользовательский флоу повторной привязки (relink); авто-действий нет. - `orphaned` — источник пропал, цель на месте: «осиротевшая» раздача, библиотечный хардлинк — единственная копия. - `deleted` — пропали и источник, и цель: терминально, действий больше нет. - Реальность «лечится» сама: если источник/цель снова появились, сверка возвращает задачу в согласованное состояние. - **Дебаунс пропажи источника.** Раздача считается удалённой только после `N` подряд тиков без неё (qBittorrent мог рестартовать / API мигнул); любое появление сбрасывает счётчик. Порог — в конфиге. - **Защита `Undo` от потери данных.** `Undo`/`layout.Undo` отказывается снимать хардлинк, если он **последняя копия** (`nlink == 1`) или исходный путь не существует — откат снимает лишний хардлинк, а не единственный файл; причина отказа сообщается явно. - **Уведомление** автору загрузки при переходе в `orphaned`/`target_missing` (через существующий `notifier`), чтобы рассинхрон не оставался незаметным. Не входит в объём (Non-goals): - Удаление раздач/файлов средствами самого jellybit (path 2, «единое окно»). - Автоматический повторный прогон распознавания/раскладки при рассинхроне — только пометка состояния; relink инициирует человек. - Сверка содержимого источника (manual delete файлов **внутри** живой раздачи → это `error`/`missingFiles` qBittorrent, отдельная тема). - Ретеншн/авточистка терминальных задач (отдельная задача в TODO). ## Capabilities ### New Capabilities - `state-reconciliation`: периодическая сверка записанного состояния загрузки с фактом на ФС (раздача в qBittorrent, разложенные хардлинки), состояния рассинхрона (`target_missing`/`orphaned`/`deleted`), их переходы и дебаунс; а также инвариант безопасного `Undo` (не снимать последнюю копию). ### Modified Capabilities ## Impact - **Спеки:** новая `openspec/specs/state-reconciliation/`; правки живых `docs/specs/workflow.md` (граф состояний: +`target_missing`/`orphaned`/ `deleted`, переходы) и `docs/specs/jellyfin-layout.md` (инвариант безопасного `Undo`). - **Код:** `internal/worker` (расширение `Poll`/`reconcile` на терминальные задачи, дебаунс, новые переходы, уведомления), `internal/store` (новые значения `state`, столбец счётчика промахов, миграция, запросы выборки desync-задач), `internal/layout` (`Undo` с проверкой `nlink`/наличия источника), `internal/qbt` (присутствие infohash — уже листаем все торренты), `internal/httpapi` + web-UI (отображение новых состояний, блокировка `Undo` для `orphaned`), `internal/config` (`[worker]` порог дебаунса). - **Конфиг:** новый ключ в `[worker]` (порог пропусков источника). - **Совместимость:** новые значения `state` — аддитивно; миграция goose для столбца счётчика.