Итог параллельной волны фиксов (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>
73 lines
5.5 KiB
Markdown
73 lines
5.5 KiB
Markdown
# 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).
|