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

73 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).