From 9bab7dc4023b045c43cbd306a73b90bbdd9a962e Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Wed, 8 Jul 2026 17:11:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=81=D1=82=D0=BE=D0=B9=D1=87=D0=B8?= =?UTF-8?q?=D0=B2=D0=BE=D1=81=D1=82=D1=8C=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B8=20=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=BE=D0=B2=20linking=20(MAJOR-4,=20MINOR-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрывает две связанные дыры «claim-then-side-effect» в раскладке хардлинками. MINOR-7: transition глотал ошибку записи состояния — на путях Apply и авто-раскладки выполнение продолжалось к хардлинкам при незакоммиченном claim перехода в linking, а финальный linking→done отклонялся графом (задача застревала со stale-планом). Выделен transitionErr, возвращающий ошибку; Apply и finishRecognition прерываются ДО linkPlan при провале claim. Обёртка transition (void) сохранена для fire-and-forget переходов — соседние функции воркера не тронуты. MAJOR-4: (A) провал CreateFileLinks после создания хардлинков больше не оставляет задачу в linking голым return — уводим в review (код persist), повтор Apply идемпотентен. (B) новый шаг pollOnce sweepLinking возвращает осиротевшие после краха linking-задачи в review (код interrupted) на тике и старте; любая linking под w.mu устарела по построению. Восстановлен инвариант «у каждого нетерминального состояния есть владелец». Граф переходов не тронут (ребро linking→review уже объявлено). Тесты: провал claim не создаёт хардлинков; провал учёта уводит в review; sweep осиротевшего linking. OpenSpec-change linking-transition-robustness (дельты file-layout, state-reconciliation). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/worker/review.go | 22 +++- internal/worker/review_test.go | 112 ++++++++++++++++++ internal/worker/worker.go | 46 ++++++- .../.openspec.yaml | 2 + .../linking-transition-robustness/design.md | 72 +++++++++++ .../linking-transition-robustness/proposal.md | 61 ++++++++++ .../specs/file-layout/spec.md | 34 ++++++ .../specs/state-reconciliation/spec.md | 33 ++++++ .../linking-transition-robustness/tasks.md | 34 ++++++ 9 files changed, 412 insertions(+), 4 deletions(-) create mode 100644 openspec/changes/linking-transition-robustness/.openspec.yaml create mode 100644 openspec/changes/linking-transition-robustness/design.md create mode 100644 openspec/changes/linking-transition-robustness/proposal.md create mode 100644 openspec/changes/linking-transition-robustness/specs/file-layout/spec.md create mode 100644 openspec/changes/linking-transition-robustness/specs/state-reconciliation/spec.md create mode 100644 openspec/changes/linking-transition-robustness/tasks.md diff --git a/internal/worker/review.go b/internal/worker/review.go index fca7a71..3cf50b6 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -194,7 +194,13 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize if res.Decision.Auto && !forceReview && w.layouter != nil { plan := applyOverrides(res.Plan, overrides) lctx := w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash()) - w.transition(lctx, *d, store.StateLinking, "", "") + // Claim перехода в linking должен закоммититься до хардлинков (MINOR-7): + // при провале записи не линкуем — задача остаётся в recognizing, и + // поллинг-цикл (recognizePending) повторит распознавание/авто-раскладку. + if err := w.transitionErr(lctx, *d, store.StateLinking, "", ""); err != nil { + logctx.From(lctx).Warn("auto-apply claim failed, left for recognizing", "error", err) + return + } if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil { logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err) } @@ -253,7 +259,13 @@ func (w *Worker) Apply(ctx context.Context, id string) error { return fmt.Errorf("apply: торрент ещё качается: %w", ErrNotReady) } - w.transition(ctx, *d, store.StateLinking, "", "") + // Claim перехода в linking ОБЯЗАН закоммититься до создания хардлинков: при + // провале записи не линкуем (иначе ссылки лягут при задаче в review, а + // финальный linking→done граф отклонит — MINOR-7). Задача остаётся в + // review/deferred, повтор безопасен. + if err := w.transitionErr(ctx, *d, store.StateLinking, "", ""); err != nil { + return fmt.Errorf("apply: %w", err) + } if err := w.linkPlan(ctx, d, plan, tag, translatePath(t.SavePath, w.cfg.PathMap)); err != nil { return fmt.Errorf("apply: %w", err) } @@ -288,6 +300,12 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize } if len(fl) > 0 { if err := w.store.CreateFileLinks(ctx, fl); err != nil { + // Хардлинки уже на диске, но их учёт не записан (транзиентная ошибка + // SQLite). НЕ оставляем задачу в linking (осиротела бы до sweep, а + // файлы висели бы без file_link — MAJOR-4): уводим в review с + // причиной. Повторный Apply идемпотентен — Apply вернёт StatusExists + // на уже созданных ссылках и допишет учёт. + w.transition(ctx, *d, store.StateReview, "persist", err.Error()) return fmt.Errorf("persist links: %w", err) } // Инвариант «один целевой путь — один владелец»: забираем владение diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index ee1d9c0..9cbdb35 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -310,6 +310,10 @@ type memStore struct { links []store.FileLink candidates []store.MetadataCandidate torrents map[string][]byte + + // Инъекция сбоев (для тестов устойчивости раскладки). + failCreateLinks error // CreateFileLinks вернёт эту ошибку + failSetState func(store.State) error // SetDownloadState вернёт ошибку для перехода } func newMemStore() *memStore { @@ -436,6 +440,11 @@ func (m *memStore) GetDownload(_ context.Context, id string) (*store.Download, e } func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error { + if m.failSetState != nil { + if err := m.failSetState(st); err != nil { + return err + } + } d := m.downloads[id] d.State = st d.ErrorCode = store.NullString(code) @@ -523,6 +532,9 @@ func (m *memStore) ListOverrides(_ context.Context, id string) (map[string]strin } func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) error { + if m.failCreateLinks != nil { + return m.failCreateLinks + } m.links = append(m.links, links...) return nil } @@ -1547,3 +1559,103 @@ func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) { }) } } + +// --- Устойчивость раскладки и переходов (MAJOR-4, MINOR-7) --- + +// TestApply_ClaimFailureAbortsBeforeLinks (MINOR-7): если запись claim перехода +// в linking падает, Apply ОБЯЗАН прерваться ДО создания хардлинков — иначе +// ссылки лягут при задаче в review, а финальный linking→done граф отклонит. +func TestApply_ClaimFailureAbortsBeforeLinks(t *testing.T) { + f := newApplyFixture(t, seriesResult().Plan) + f.st.failSetState = func(st store.State) error { + if st == store.StateLinking { + return errors.New("boom: claim persist failed") + } + return nil + } + + err := f.w.Apply(context.Background(), "1") + if err == nil { + t.Fatal("Apply must fail when linking claim persist fails") + } + if f.st.downloads["1"].State != store.StateReview { + t.Errorf("state = %q, want review (claim not committed)", f.st.downloads["1"].State) + } + if len(f.st.links) != 0 { + t.Errorf("file_links = %d, want 0 (no linking before committed claim)", len(f.st.links)) + } + // Хардлинки на диск НЕ созданы — раскладка не запускалась. + dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") + if _, statErr := os.Stat(dst); statErr == nil { + t.Errorf("hardlink %q created despite failed claim", dst) + } +} + +// TestApply_PersistFailureLeavesReview (MAJOR-4 A): хардлинки созданы, но +// CreateFileLinks упал транзиентно → задача не должна застрять в linking; уходит +// в review с причиной, повтор идемпотентен. +func TestApply_PersistFailureLeavesReview(t *testing.T) { + f := newApplyFixture(t, seriesResult().Plan) + f.st.failCreateLinks = errors.New("boom: sqlite busy") + + err := f.w.Apply(context.Background(), "1") + if err == nil { + t.Fatal("Apply must fail when persisting links fails") + } + if f.st.downloads["1"].State != store.StateReview { + t.Fatalf("state = %q, want review (not stranded in linking)", f.st.downloads["1"].State) + } + if f.st.downloads["1"].ErrorCode.String != "persist" { + t.Errorf("error_code = %q, want persist", f.st.downloads["1"].ErrorCode.String) + } + // Хардлинки уже на диске (учёт лишь не записан) — повторный Apply их допишет. + dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") + if _, statErr := os.Stat(dst); statErr != nil { + t.Errorf("expected hardlink on disk despite persist failure: %v", statErr) + } +} + +// TestSweepLinking_OrphanedToReview (MAJOR-4 B): задача, застрявшая в linking +// после краха, на тике/старте возвращается в review с причиной. +func TestSweepLinking_OrphanedToReview(t *testing.T) { + st := newMemStore() + d := completedDownload("1") + d.State = store.StateLinking + st.put(d) + w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + + w.sweepLinking(context.Background()) + + got := st.downloads["1"] + if got.State != store.StateReview { + t.Fatalf("state = %q, want review", got.State) + } + if got.ErrorCode.String != "interrupted" { + t.Errorf("error_code = %q, want interrupted", got.ErrorCode.String) + } + if got.ErrorMsg.String == "" { + t.Error("expected error_msg with reason") + } +} + +// TestSweepLinking_LeavesOtherStates: sweep трогает только linking, прочие +// состояния (в т.ч. done) не задевает. +func TestSweepLinking_LeavesOtherStates(t *testing.T) { + st := newMemStore() + done := completedDownload("1") + done.State = store.StateDone + st.put(done) + review := completedDownload("2") + review.State = store.StateReview + st.put(review) + w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + + w.sweepLinking(context.Background()) + + if st.downloads["1"].State != store.StateDone { + t.Errorf("done task moved to %q", st.downloads["1"].State) + } + if st.downloads["2"].State != store.StateReview { + t.Errorf("review task moved to %q", st.downloads["2"].State) + } +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 05d7f2d..5447fac 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -322,12 +322,39 @@ func (w *Worker) pollOnce(ctx context.Context) { // Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные // (catched) загрузки и добавляем их (сеть — вне блокировки переходов). w.processCatched(ctx) + // Восстанавливаем задачи, застрявшие в linking после краха между claim и + // финальным переходом (иначе их не листит никто — вечный лимбо). + w.sweepLinking(ctx) // Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке). if w.recognizer != nil { w.recognizePending(ctx) } } +// sweepLinking восстанавливает задачи, застрявшие в состоянии linking. Любая +// linking-задача, видимая под w.mu, устарела по построению: активная раскладка +// (linkPlan) держит w.mu на всё время и завершает переход из linking ДО отпускания +// замка — значит эта задача осталась в linking после краха процесса между claim +// (переходом в linking) и финальным переходом. Возвращаем её в review с причиной; +// человек повторит Apply (linkPlan идемпотентен), и незаписанный учёт хардлинков +// допишется. Так у linking появляется владелец на рестарте/тике — инвариант «у +// каждого нетерминального состояния есть владелец» (как recognizePending для +// recognizing). Выполняется на каждом тике и на старте (первый pollOnce до цикла). +func (w *Worker) sweepLinking(ctx context.Context) { + w.mu.Lock() + defer w.mu.Unlock() + stuck, err := w.store.ListDownloadsByState(ctx, store.StateLinking) + if err != nil { + w.log.Warn("sweep linking list failed", "capability", capFileLayout, "error", err) + return + } + for _, d := range stuck { + lctx := w.scoped(ctx, capFileLayout, d.ID, d.PrimaryInfohash()) + w.transition(lctx, d, store.StateReview, "interrupted", + "прерванная раскладка, повтори применение") + } +} + // processCatched — асинхронный шаг добавления пойманных загрузок в qBittorrent. // Для каждой catched: (предохранитель) если висит дольше catch_timeout — уводим // в failed; иначе выводим имя и добавляем в qBit. Медленные вызовы (LLM-namer, @@ -641,14 +668,28 @@ func (w *Worker) retriedFloor(d store.Download, basis time.Time) time.Time { return basis } -// transition пишет новое состояние и логирует переход. +// transition пишет новое состояние и логирует переход. Fire-and-forget обёртка +// над transitionErr: применяется там, где переход терминален для шага — за ним +// нет побочного эффекта, зависящего от факта записи claim (reconcile, таймауты, +// команды ревью, финальные переходы linkPlan, sweep). Ошибку записи гасит (её +// уже залогировал transitionErr). func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) { + _ = w.transitionErr(ctx, d, state, code, msg) +} + +// transitionErr пишет новое состояние, шлёт пинги/скан, логирует переход и +// ВОЗВРАЩАЕТ ошибку записи. На claim-then-side-effect путях (ручное Apply, +// авто-раскладка в finishRecognition) провал claim перехода в `linking` ОБЯЗАН +// прервать выполнение ДО побочных эффектов (хардлинков): иначе ссылки лягут при +// незакоммиченном claim, а финальный переход из фактического (не `linking`) +// состояния граф отклонит — задача застрянет со stale-планом (MINOR-7). +func (w *Worker) transitionErr(ctx context.Context, d store.Download, state store.State, code, msg string) error { // FromOr, а не From: если вызывающий не завёл scoped-логгер, падаем на // w.log (настроенный), а не на slog.Default(). log := logctx.FromOr(ctx, w.log) if err := w.store.SetDownloadState(ctx, d.ID, state, code, msg); err != nil { log.Error("state transition failed", "from", d.State, "to", state, "error", err) - return + return fmt.Errorf("transition %s → %s: %w", d.State, state, err) } log.Info("state transition", "from", d.State, "to", state, "code", code) @@ -681,6 +722,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.PrimaryInfohash()) go func() { _ = w.scanner.RefreshLibraries(gctx) }() } + return nil } // shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи diff --git a/openspec/changes/linking-transition-robustness/.openspec.yaml b/openspec/changes/linking-transition-robustness/.openspec.yaml new file mode 100644 index 0000000..8cceb8d --- /dev/null +++ b/openspec/changes/linking-transition-robustness/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-08 diff --git a/openspec/changes/linking-transition-robustness/design.md b/openspec/changes/linking-transition-robustness/design.md new file mode 100644 index 0000000..45e71c7 --- /dev/null +++ b/openspec/changes/linking-transition-robustness/design.md @@ -0,0 +1,72 @@ +# Design + +## Контекст + +`linking` — короткое рабочее состояние между «решили раскладывать» и +«разложили». Оно нетерминально и активно, но в отличие от `recognizing` его +никто не листит на рестарте, а переход в него — обычный `SetDownloadState`, чей +сбой раньше проглатывался. Обе дыры (MINOR-7, MAJOR-4) — про то, что `linking` +не был устойчивым владельцем шага. + +## Решение 1: `transition` возвращает ошибку — но только там, где она нужна + +Параллельный поток правит соседние функции воркера (`Retry`/`checkTimeouts`/ +`torrentAge`), поэтому смена сигнатуры `transition` на всех ~20 вызовах +(с добавлением `_ =` в fire-and-forget местах) создала бы лишние конфликты +слияния и шум. Вместо этого: + +- `transition(...)` остаётся `void` — обёртка, гасящая ошибку. Все существующие + вызовы (reconcile, таймауты, команды ревью, финальные переходы `linkPlan`, + sweep) не трогаются: за ними НЕТ побочного эффекта, зависящего от факта записи + claim, — переход и есть конец шага. +- `transitionErr(...)` — новая функция, тело прежнего `transition` + `return + error`. Пинги/скан живут в ней (обёртка делегирует). + +**Развилка:** менять сигнатуру `transition` глобально (честнее, но шумно и +конфликтно) против точечного `transitionErr` (`mustTransition` из ревью). Выбран +точечный вариант: минимальный след, локальные правки по функциям, ошибка +возвращается ровно там, где за claim следует побочный эффект. + +Использование: `Apply` и авто-раскладка в `finishRecognition` зовут +`transitionErr(StateLinking)` и прерываются при ошибке ДО `linkPlan`. При провале +claim `Apply` остаётся в `review`/`deferred`, а авто-путь — в `recognizing` +(его повторит `recognizePending`); в обоих случаях владелец шага сохраняется. + +## Решение 2: провал `CreateFileLinks` уводит в `review`, а не оставляет в `linking` + +Хардлинки к этому моменту уже на диске — это учётный, а не безопасностный сбой +(файлы разложены). Оставлять задачу в `linking` нельзя (осиротеет до sweep, а до +того файлы висят без `file_link`). Уводим в `review` с кодом `persist`: повторный +`Apply` идемпотентен — `layout.Apply` вернёт `StatusExists` на уже созданных +ссылках, а `CreateFileLinks` допишет учёт. + +**Почему `review`, а не `failed`:** план валиден, сбой транзиентный, самолечение +через повтор естественно ложится в петлю ревью (как коллизия). `failed` уводил +бы в восстановление сверкой, которое к этому кейсу не относится. + +Соседний сбой `SupersedeForeignLinks` уже трактуется как учётный (WARN, доводим +до `done`) — тот кейс не меняем: там файлы разложены И учтены, чужой рассинхрон +починит следующий тик сверки. + +## Решение 3: sweep осиротевшего `linking` на тике и старте + +Новый шаг `sweepLinking` в `pollOnce` (выполняется и первым вызовом до цикла — +это «старт»). Берёт `w.mu`, листит `linking`, каждую переводит `linking → review` +(ребро уже в графе) с кодом `interrupted`. + +**Ключ корректности:** активная раскладка (`linkPlan`) держит `w.mu` на весь свой +срок и завершает переход ИЗ `linking` до отпускания замка. Значит любая +`linking`-задача, которую `sweepLinking` видит, взяв `w.mu`, гарантированно НЕ +в полёте — она осталась после краша между claim и финальным переходом. Ложных +срабатываний на живой раскладке нет. + +Так `linking` получает владельца на рестарте/тике — по образцу `recognizing` +(`recognizePending`); инвариант «у каждого нетерминального состояния есть +владелец» восстановлен. + +## Что НЕ делаем + +- Не трогаем граф переходов: ребро `linking → review` уже объявлено в + `allowedTransitions`. +- Не меняем схему БД. +- Не меняем сигнатуру `transition` глобально (см. Решение 1). diff --git a/openspec/changes/linking-transition-robustness/proposal.md b/openspec/changes/linking-transition-robustness/proposal.md new file mode 100644 index 0000000..64ab7d6 --- /dev/null +++ b/openspec/changes/linking-transition-robustness/proposal.md @@ -0,0 +1,61 @@ +## Why + +Раскладка хардлинками устроена как «claim-then-side-effect»: сначала задача +переводится в `linking` (claim владения шагом), затем создаются хардлинки и +пишется их учёт (`file_link`). Ревью жизненного цикла (Fable, 2026-07-08) +нашло две связанные дыры устойчивости этого пути. + +- **MINOR-7:** `worker.transition` при ошибке записи состояния логировал её, но + НЕ возвращал вызывающему. На путях `Apply` и авто-раскладки в + `finishRecognition` выполнение продолжалось к побочным эффектам: хардлинки + создавались, пока claim перехода в `linking` не закоммичен. Финальный переход + `linking → done` оценивался графом как `review → done` (нелегальное ребро) и + отклонялся — задача застревала в `review` со stale-планом, скан/уведомление не + срабатывали. +- **MAJOR-4:** задача может осиротеть в `linking`: + (A) без краха — хардлинки созданы, но `CreateFileLinks` упал транзиентно + (SQLite busy) → голый `return` оставлял задачу в `linking`, а файлы на диске — + без строк `file_link`; + (B) краш процесса между переходом в `linking` и финальным переходом → на + рестарте `linking` не листит НИКТО (поллинг листит `downloading`, распознавание + — `completed`/`recognizing`, сверка — `done`/`target_missing`/`orphaned`, + восстановление — `failed`/`stuck`). Задача сидит в `linking` вечно; выход — + только ручной Cancel/Defer (недискаверабельно). Нарушен инвариант «у каждого + нетерминального состояния есть владелец» (`recognizing` уже лечится + рестартом через `recognizePending`, `linking` — нет). + +## What Changes + +- `transition` разделяется на fire-and-forget обёртку (прежнее имя, прежнее + поведение для reconcile/таймаутов/финальных переходов) и `transitionErr`, + которая ВОЗВРАЩАЕТ ошибку записи. На claim-then-side-effect путях (`Apply`, + авто-раскладка в `finishRecognition`) провал claim перехода в `linking` теперь + прерывает выполнение ДО хардлинков. +- В `linkPlan` провал `CreateFileLinks` больше не оставляет задачу в `linking`: + задача уходит в `review` с кодом `persist` и причиной; повторный `Apply` + идемпотентен (хардлинки уже на диске → `StatusExists`, учёт дописывается). +- Новый шаг поллинга `sweepLinking`: на каждом тике и на старте задачи в + `linking` возвращаются в `review` с кодом `interrupted` и причиной + «прерванная раскладка, повтори применение». Любая `linking`, видимая под + `w.mu`, устарела по построению (активная раскладка держит `w.mu` весь свой + срок), значит осталась после краха. + +## Capabilities + +### Modified Capabilities + +- `file-layout`: раскладка становится устойчивой к сбою записи claim/учёта — + хардлинки не создаются при незакоммиченном claim, а сбой записи учёта не + стрэндит задачу в `linking`. +- `state-reconciliation`: у нетерминального `linking` появляется владелец на + рестарте/тике — sweep осиротевших `linking` в `review`. + +## Impact + +- **Код:** `internal/worker/worker.go` (`transition`/`transitionErr`, `pollOnce`, + `sweepLinking`), `internal/worker/review.go` (`Apply`, `finishRecognition`, + `linkPlan`). Граф переходов (`internal/store/download.go`) правки не требует — + ребро `linking → review` уже объявлено. +- **Тесты:** провал claim прерывает до хардлинков; провал `CreateFileLinks` + уводит в `review` (файлы на диске); sweep осиротевшего `linking` → `review`. +- **БД/схема:** без изменений. diff --git a/openspec/changes/linking-transition-robustness/specs/file-layout/spec.md b/openspec/changes/linking-transition-robustness/specs/file-layout/spec.md new file mode 100644 index 0000000..b06527f --- /dev/null +++ b/openspec/changes/linking-transition-robustness/specs/file-layout/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Claim раскладки коммитится до хардлинков и устойчив к сбою учёта + +Раскладка — «claim-then-side-effect»: система SHALL сперва зафиксировать переход +задачи в `linking` (claim шага раскладки), и только затем создавать хардлинки. +Если запись claim перехода в `linking` провалилась, система НЕ SHALL создавать +хардлинки и SHALL прервать раскладку, оставив задачу в исходном состоянии +(`review`/`deferred` при ручном применении; `recognizing` при авто-раскладке) — +чтобы у шага сохранился владелец, а хардлинки не легли при незакоммиченном claim +(иначе финальный переход `linking → done` из фактического состояния был бы +отклонён графом, и задача застряла бы со stale-планом). + +Если хардлинки уже созданы, но запись их учёта (`file_link`) провалилась +(транзиентная ошибка хранилища), задача НЕ SHALL оставаться в `linking`: система +SHALL перевести её в `review` с причиной. Повторное применение SHALL быть +идемпотентным — уже созданные хардлинки распознаются как существующие +(`StatusExists`), а их учёт дописывается. + +#### Scenario: Провал claim не создаёт хардлинков + +- **GIVEN** задача в `review` с готовым источником и валидным планом +- **WHEN** запись перехода в `linking` проваливается +- **THEN** хардлинки не создаются, учёт `file_link` не пишется +- **AND** задача остаётся в `review`, а команда отказывает с ошибкой + +#### Scenario: Провал учёта уводит в review, не оставляя в linking + +- **GIVEN** хардлинки по плану уже созданы на файловой системе +- **WHEN** запись строк `file_link` проваливается транзиентной ошибкой +- **THEN** задача переходит в `review` с причиной (код `persist`), а не остаётся + в `linking` +- **AND** созданные хардлинки остаются на диске +- **AND** повторное «Применить» идемпотентно дописывает учёт и доводит до `done` diff --git a/openspec/changes/linking-transition-robustness/specs/state-reconciliation/spec.md b/openspec/changes/linking-transition-robustness/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..4d7e7c3 --- /dev/null +++ b/openspec/changes/linking-transition-robustness/specs/state-reconciliation/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Восстановление задачи, застрявшей в linking + +Система SHALL на каждом тике поллинга и при старте выявлять задачи в состоянии +`linking` и возвращать их в `review` с причиной «прерванная раскладка» (код +`interrupted`), откуда человек повторит применение (повтор идемпотентен). +`linking` — нетерминальное активное состояние, и у него, как у каждого +нетерминального состояния, ДОЛЖЕН быть владелец, продвигающий задачу; иначе +краш процесса между переходом в `linking` и финальным переходом оставил бы +задачу без владельца — её не листит ни один штатный шаг (ни поллинг активных, +ни распознавание, ни матрица сверки, ни восстановление `failed`/`stuck`). + +Выявление SHALL выполняться под той же блокировкой переходов, что и раскладка: +активная раскладка удерживает блокировку весь свой срок и завершает переход из +`linking` до её отпускания, поэтому любая `linking`-задача, наблюдаемая под +блокировкой, по построению устарела (осталась после краха) — восстановление НЕ +SHALL задевать раскладку в полёте. + +#### Scenario: Осиротевший linking возвращается в review + +- **GIVEN** задача осталась в `linking` после краха между claim и финальным + переходом +- **WHEN** выполняется тик поллинга (или старт сервиса) +- **THEN** задача переходит в `review` с причиной «прерванная раскладка» + (код `interrupted`) +- **AND** её можно повторно применить из ревью + +#### Scenario: Прочие состояния sweep не задевает + +- **GIVEN** задачи в состояниях `done` и `review` +- **WHEN** выполняется тик поллинга +- **THEN** восстановление `linking` их состояние не меняет diff --git a/openspec/changes/linking-transition-robustness/tasks.md b/openspec/changes/linking-transition-robustness/tasks.md new file mode 100644 index 0000000..c50db08 --- /dev/null +++ b/openspec/changes/linking-transition-robustness/tasks.md @@ -0,0 +1,34 @@ +## 1. Возврат ошибки перехода (MINOR-7) + +- [x] 1.1 Разделить `transition` на `void`-обёртку и `transitionErr` + (возвращает ошибку записи); пинги/скан — в `transitionErr` +- [x] 1.2 `Apply`: заменить claim `transition(StateLinking)` на + `transitionErr` с прерыванием до `linkPlan` при ошибке +- [x] 1.3 `finishRecognition` (авто-раскладка): то же — при провале claim + остаёмся в `recognizing`, `linkPlan` не зовём + +## 2. Провал учёта не оставляет в linking (MAJOR-4 A) + +- [x] 2.1 В `linkPlan` при провале `CreateFileLinks` перевести задачу в + `review` (код `persist`) вместо голого `return` + +## 3. Sweep осиротевшего linking (MAJOR-4 B) + +- [x] 3.1 Добавить `sweepLinking`: под `w.mu` листить `linking` и переводить + `linking → review` (код `interrupted`, причина «прерванная раскладка, + повтори применение») +- [x] 3.2 Вызвать `sweepLinking` в `pollOnce` (тик + старт) + +## 4. Тесты + +- [x] 4.1 Провал claim перехода в `linking` прерывает `Apply` до хардлинков + (нет файлов на диске, нет `file_link`, состояние `review`) +- [x] 4.2 Провал `CreateFileLinks` уводит в `review` (файлы на диске есть, + код `persist`) +- [x] 4.3 `sweepLinking` переводит осиротевший `linking` в `review` + (код `interrupted`); прочие состояния не задевает + +## 5. Проверки + +- [x] 5.1 `task test` и `task lint` проходят +- [x] 5.2 `openspec validate linking-transition-robustness --strict` проходит