Устойчивость раскладки и переходов 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 {
|
||||
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)
|
||||
}
|
||||
// Инвариант «один целевой путь — один владелец»: забираем владение
|
||||
|
||||
Reference in New Issue
Block a user