Обработка рассинхрона состояния с реальностью (state-reconciliation)

Распознаём ручное удаление источника (раздача в qBittorrent) и/или цели
(разложенные хардлинки) и отражаем его в состоянии задачи, без автодействий.

- Новая capability state-reconciliation (OpenSpec): фоновая сверка по матрице
  «источник × цель» → состояния target_missing/orphaned/deleted, переходы и
  самовосстановление (healing).
- worker: reconcileDesync в Poll (только разложенные/desync-задачи), дебаунс
  пропажи источника (порог [worker].source_missing_threshold) и синхронный
  preflight перед действиями (relink/recognize/apply/undo) — не доверяем
  state в БД.
- layout.Undo: отказ снять последнюю копию (nlink<=1 или нет источника),
  отказ всего батча без частичного отката (ErrLastCopy).
- store: единый список terminalStates для IsTerminal и FindActiveByInfohash
  (иначе семантика «активности» разъезжается), столбец source_miss_count,
  миграция 0003.
- httpapi/web и Telegram: показ новых состояний и уведомления о рассинхроне.
- Доки: workflow.md, jellyfin-layout.md, database.md (+0003), config.

Change заархивирован в openspec/changes/archive, дельта влита в openspec/specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
av
2026-06-29 11:07:09 +03:00
co-authored by Claude Opus 4.8
parent f9d7fd9216
commit cc7e51b3a4
29 changed files with 1556 additions and 80 deletions
+54
View File
@@ -232,6 +232,60 @@ func TestUndo_RemovesLinksAndPrunesDirs(t *testing.T) {
}
}
func TestUndo_RefusesLastCopyWhenSourceGone(t *testing.T) {
f := newFixture(t)
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
Files: []PlanFile{{Src: f.srcFile(t, "m/film.mkv", "data"), Role: RoleMain}}})
if _, err := f.l.Apply(context.Background(), links); err != nil {
t.Fatal(err)
}
// Источник удалён (как при удалении раздачи из qBittorrent) → цель стала
// последней копией (nlink упал до 1).
if err := os.Remove(links[0].Src); err != nil {
t.Fatal(err)
}
n, err := f.l.Undo(context.Background(), links)
if !errors.Is(err, ErrLastCopy) {
t.Fatalf("err = %v, want ErrLastCopy", err)
}
if n != 0 {
t.Errorf("removed = %d, want 0 (ничего не сняли)", n)
}
// Цель НЕ удалена — данные целы.
if _, err := os.Stat(links[0].Dst); err != nil {
t.Errorf("target must remain (last copy): %v", err)
}
}
func TestUndo_RefusesWholeBatchIfAnyLastCopy(t *testing.T) {
f := newFixture(t)
links, _ := f.l.BuildLinks(Plan{Type: Series, Title: "Show", Year: 2021,
Files: []PlanFile{
{Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
{Src: f.srcFile(t, "s/e2.mkv", "2"), Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
}})
if _, err := f.l.Apply(context.Background(), links); err != nil {
t.Fatal(err)
}
// Источник второй серии пропал — весь батч должен быть отклонён целиком.
if err := os.Remove(links[1].Src); err != nil {
t.Fatal(err)
}
n, err := f.l.Undo(context.Background(), links)
if !errors.Is(err, ErrLastCopy) {
t.Fatalf("err = %v, want ErrLastCopy", err)
}
if n != 0 {
t.Errorf("removed = %d, want 0 (батч не трогаем)", n)
}
// Первая ссылка (источник жив) НЕ снята — частичного отката нет.
if _, err := os.Stat(links[0].Dst); err != nil {
t.Errorf("first target must remain (no partial undo): %v", err)
}
}
func TestUndo_Idempotent(t *testing.T) {
f := newFixture(t)
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,