Устойчивость раскладки и переходов linking (MAJOR-4, MINOR-7)
Закрывает две связанные дыры «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) <noreply@anthropic.com>
This commit is contained in:
@@ -194,7 +194,13 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize
|
|||||||
if res.Decision.Auto && !forceReview && w.layouter != nil {
|
if res.Decision.Auto && !forceReview && w.layouter != nil {
|
||||||
plan := applyOverrides(res.Plan, overrides)
|
plan := applyOverrides(res.Plan, overrides)
|
||||||
lctx := w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
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 {
|
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
|
||||||
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
|
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)
|
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 {
|
if err := w.linkPlan(ctx, d, plan, tag, translatePath(t.SavePath, w.cfg.PathMap)); err != nil {
|
||||||
return fmt.Errorf("apply: %w", err)
|
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 len(fl) > 0 {
|
||||||
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
|
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)
|
return fmt.Errorf("persist links: %w", err)
|
||||||
}
|
}
|
||||||
// Инвариант «один целевой путь — один владелец»: забираем владение
|
// Инвариант «один целевой путь — один владелец»: забираем владение
|
||||||
|
|||||||
@@ -310,6 +310,10 @@ type memStore struct {
|
|||||||
links []store.FileLink
|
links []store.FileLink
|
||||||
candidates []store.MetadataCandidate
|
candidates []store.MetadataCandidate
|
||||||
torrents map[string][]byte
|
torrents map[string][]byte
|
||||||
|
|
||||||
|
// Инъекция сбоев (для тестов устойчивости раскладки).
|
||||||
|
failCreateLinks error // CreateFileLinks вернёт эту ошибку
|
||||||
|
failSetState func(store.State) error // SetDownloadState вернёт ошибку для перехода
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMemStore() *memStore {
|
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 {
|
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 := m.downloads[id]
|
||||||
d.State = st
|
d.State = st
|
||||||
d.ErrorCode = store.NullString(code)
|
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 {
|
func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) error {
|
||||||
|
if m.failCreateLinks != nil {
|
||||||
|
return m.failCreateLinks
|
||||||
|
}
|
||||||
m.links = append(m.links, links...)
|
m.links = append(m.links, links...)
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -322,12 +322,39 @@ func (w *Worker) pollOnce(ctx context.Context) {
|
|||||||
// Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные
|
// Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные
|
||||||
// (catched) загрузки и добавляем их (сеть — вне блокировки переходов).
|
// (catched) загрузки и добавляем их (сеть — вне блокировки переходов).
|
||||||
w.processCatched(ctx)
|
w.processCatched(ctx)
|
||||||
|
// Восстанавливаем задачи, застрявшие в linking после краха между claim и
|
||||||
|
// финальным переходом (иначе их не листит никто — вечный лимбо).
|
||||||
|
w.sweepLinking(ctx)
|
||||||
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
|
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
|
||||||
if w.recognizer != nil {
|
if w.recognizer != nil {
|
||||||
w.recognizePending(ctx)
|
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.
|
// processCatched — асинхронный шаг добавления пойманных загрузок в qBittorrent.
|
||||||
// Для каждой catched: (предохранитель) если висит дольше catch_timeout — уводим
|
// Для каждой catched: (предохранитель) если висит дольше catch_timeout — уводим
|
||||||
// в failed; иначе выводим имя и добавляем в qBit. Медленные вызовы (LLM-namer,
|
// в failed; иначе выводим имя и добавляем в qBit. Медленные вызовы (LLM-namer,
|
||||||
@@ -641,14 +668,28 @@ func (w *Worker) retriedFloor(d store.Download, basis time.Time) time.Time {
|
|||||||
return basis
|
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) {
|
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-логгер, падаем на
|
// FromOr, а не From: если вызывающий не завёл scoped-логгер, падаем на
|
||||||
// w.log (настроенный), а не на slog.Default().
|
// w.log (настроенный), а не на slog.Default().
|
||||||
log := logctx.FromOr(ctx, w.log)
|
log := logctx.FromOr(ctx, w.log)
|
||||||
if err := w.store.SetDownloadState(ctx, d.ID, state, code, msg); err != nil {
|
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)
|
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)
|
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())
|
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.PrimaryInfohash())
|
||||||
go func() { _ = w.scanner.RefreshLibraries(gctx) }()
|
go func() { _ = w.scanner.RefreshLibraries(gctx) }()
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи
|
// shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-08
|
||||||
@@ -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).
|
||||||
@@ -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`.
|
||||||
|
- **БД/схема:** без изменений.
|
||||||
@@ -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`
|
||||||
@@ -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` их состояние не меняет
|
||||||
@@ -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` проходит
|
||||||
Reference in New Issue
Block a user