Files
jellybit/openspec/changes/linking-transition-robustness/design.md
T
avandClaude Opus 4.8 9bab7dc402 Устойчивость раскладки и переходов 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>
2026-07-08 17:18:28 +03:00

5.5 KiB
Raw Blame History

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); в обоих случаях владелец шага сохраняется.

Хардлинки к этому моменту уже на диске — это учётный, а не безопасностный сбой (файлы разложены). Оставлять задачу в 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).