Итог параллельной волны фиксов (worktree-изоляция, cherry-pick в master): - ingest-dedup-integrity (F1, F6) → спека ingest - retry-stall-basis (MAJOR-1, MAJOR-2) → спека state-reconciliation - linking-transition-robustness (MAJOR-4, MINOR-7) → спеки file-layout и state-reconciliation Дельты влиты в openspec/specs, changes перенесены в openspec/changes/archive/2026-07-08-*. Беклог не трогаю (по решению). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5.5 KiB
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).