Files
avandClaude Opus 4.8 6b7c090ce4 Владение целевым путём при повторной раскладке (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>
2026-06-29 18:10:05 +03:00

71 lines
5.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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` по уже удалённым загрузкам; повторная закачка того же
фильма в другом качестве больше не «трогает» прежнюю задачу.