Владение целевым путём при повторной раскладке (state-reconciliation)
Завершённая загрузка ложно «воскресала» из deleted в orphaned, когда её целевой путь переиспользовала другая загрузка (повторная закачка того же фильма в другом качестве): сверка проверяла лишь существование пути, не проверяя, что файл по нему — наша раскладка. Вводим инвариант «один целевой путь — один владелец»: - при успешной раскладке на освободившийся чужой путь владение переходит к новой загрузке — прежние file_link на этот путь помечаются статусом superseded и перестают считаться целью при сверке; - deleted исключён из desyncStates — терминальное состояние больше не переоценивается (источник к нему не вернётся из-за идемпотентности, цель отбирается переходом владения); - Undo снимает только реально свои разложенные ссылки (superseded пропускает — файл по пути теперь чужой хардлинк); - ошибку перехода владения трактуем как некритичную (WARN-and-continue): файлы уже разложены, рассинхрон чужих задач исправит следующий тик. Без миграции схемы (status — TEXT). Дельта влита в основную спеку, обновлены workflow.md и jellyfin-layout.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
## Why
|
||||
|
||||
Фоновая сверка может ложно «воскресить» завершённую загрузку, если её
|
||||
целевой путь переиспользовала другая загрузка. Реальный инцидент: фильм
|
||||
скачали в 4K (download A), затем удалили его из qBittorrent и Jellyfin —
|
||||
сверка увела A в терминальный `deleted` (нет ни источника, ни цели). После
|
||||
этого тот же фильм скачали в 1080p (download B); он распознался как тот же
|
||||
фильм и сделал хардлинк по **тому же** `dst_path`. На следующем тике сверка
|
||||
для A увидела, что файл по пути снова существует (хотя это файл B), и
|
||||
вывела `deleted → orphaned` с ложным уведомлением «источник потерян».
|
||||
|
||||
Корень: `targetPresent` считает цель присутствующей по факту существования
|
||||
**пути**, не проверяя, что лежащая там раскладка принадлежит **этой**
|
||||
загрузке. Один `dst_path` может оказаться «своим» сразу для двух загрузок.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Вводим инвариант **«один целевой путь — один владелец»**: цель загрузки
|
||||
считается присутствующей, только если разложенные по её путям ссылки всё
|
||||
ещё принадлежат именно ей. Когда новая раскладка ложится на путь, ранее
|
||||
занятый другой загрузкой (путь к тому моменту свободен — иначе была бы
|
||||
коллизия → review), владение переходит к новой загрузке, а ссылки прежней
|
||||
на этот путь помечаются вышедшими из обращения (новый статус `file_link`
|
||||
— `superseded`).
|
||||
- Делаем `deleted` действительно **терминальным** для сверки: исключаем его
|
||||
из набора сверяемых состояний — завершённую начисто задачу больше не
|
||||
переоценивают (источник к ней не вернётся из-за идемпотентности
|
||||
терминальных задач, а цель отбирается переходом владения).
|
||||
- Приводим спеку в соответствие с графом состояний: убираем из требования
|
||||
«самовосстановление» возврат из `deleted` (он противоречил диаграмме
|
||||
`deleted --> [*]` в `workflow.md` и под новым инвариантом нереализуем).
|
||||
- Коллизия остаётся как есть: если файл прежней загрузки **всё ещё на
|
||||
месте**, новая раскладка не перезаписывает его, а уходит в review.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(нет)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `state-reconciliation`: присутствие цели определяется по **владению**, а
|
||||
не по факту существования пути; вводится переход владения путём при
|
||||
повторной раскладке; `deleted` исключается из сверки и из
|
||||
самовосстановления.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Затрагиваемый код:**
|
||||
- `internal/worker/reconcile.go` — убрать `StateDeleted` из
|
||||
`desyncStates`; `targetPresent`/`isLaidOut` уже считают целью только
|
||||
`linked/copied/exists`, новый статус `superseded` отсекается
|
||||
автоматически.
|
||||
- `internal/worker/review.go` (`linkPlan`) — после фиксации своих ссылок
|
||||
пометить чужие `file_link` на тех же `dst_path` как `superseded`;
|
||||
покрывает и авто-раскладку, и ручной `Apply` (обе идут через
|
||||
`linkPlan`).
|
||||
- `internal/store/recognition.go` — новый метод стора (supersede чужих
|
||||
ссылок по списку `dst_path`).
|
||||
- `internal/layout/layout.go` — константа статуса `StatusSuperseded`.
|
||||
- **Без миграции схемы:** `file_link.status` — `TEXT`, новое значение
|
||||
enum-а не меняет таблицу. ER-схема `docs/specs/database.md` не меняется.
|
||||
- **Документация:** `docs/specs/workflow.md` (раздел «Сверка с
|
||||
реальностью» — `deleted` терминален) и `docs/specs/jellyfin-layout.md`
|
||||
(переход владения путём при повторной раскладке) — `file-layout` ещё не
|
||||
перенесён в OpenSpec, источник истины по нему — `docs/specs/`.
|
||||
- **Поведение пользователя:** исчезают ложные уведомления `orphaned`/
|
||||
`target_missing` по уже удалённым загрузкам; повторная закачка того же
|
||||
фильма в другом качестве больше не «трогает» прежнюю задачу.
|
||||
Reference in New Issue
Block a user