# 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).