Устойчивость раскладки и переходов 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>
This commit is contained in:
av
2026-07-08 17:18:28 +03:00
co-authored by Claude Opus 4.8
parent 8261d5b55d
commit 9bab7dc402
9 changed files with 412 additions and 4 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-08
@@ -0,0 +1,72 @@
# 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).
@@ -0,0 +1,61 @@
## Why
Раскладка хардлинками устроена как «claim-then-side-effect»: сначала задача
переводится в `linking` (claim владения шагом), затем создаются хардлинки и
пишется их учёт (`file_link`). Ревью жизненного цикла (Fable, 2026-07-08)
нашло две связанные дыры устойчивости этого пути.
- **MINOR-7:** `worker.transition` при ошибке записи состояния логировал её, но
НЕ возвращал вызывающему. На путях `Apply` и авто-раскладки в
`finishRecognition` выполнение продолжалось к побочным эффектам: хардлинки
создавались, пока claim перехода в `linking` не закоммичен. Финальный переход
`linking → done` оценивался графом как `review → done` (нелегальное ребро) и
отклонялся — задача застревала в `review` со stale-планом, скан/уведомление не
срабатывали.
- **MAJOR-4:** задача может осиротеть в `linking`:
(A) без краха — хардлинки созданы, но `CreateFileLinks` упал транзиентно
(SQLite busy) → голый `return` оставлял задачу в `linking`, а файлы на диске —
без строк `file_link`;
(B) краш процесса между переходом в `linking` и финальным переходом → на
рестарте `linking` не листит НИКТО (поллинг листит `downloading`, распознавание
`completed`/`recognizing`, сверка — `done`/`target_missing`/`orphaned`,
восстановление — `failed`/`stuck`). Задача сидит в `linking` вечно; выход —
только ручной Cancel/Defer (недискаверабельно). Нарушен инвариант «у каждого
нетерминального состояния есть владелец» (`recognizing` уже лечится
рестартом через `recognizePending`, `linking` — нет).
## What Changes
- `transition` разделяется на fire-and-forget обёртку (прежнее имя, прежнее
поведение для reconcile/таймаутов/финальных переходов) и `transitionErr`,
которая ВОЗВРАЩАЕТ ошибку записи. На claim-then-side-effect путях (`Apply`,
авто-раскладка в `finishRecognition`) провал claim перехода в `linking` теперь
прерывает выполнение ДО хардлинков.
- В `linkPlan` провал `CreateFileLinks` больше не оставляет задачу в `linking`:
задача уходит в `review` с кодом `persist` и причиной; повторный `Apply`
идемпотентен (хардлинки уже на диске → `StatusExists`, учёт дописывается).
- Новый шаг поллинга `sweepLinking`: на каждом тике и на старте задачи в
`linking` возвращаются в `review` с кодом `interrupted` и причиной
«прерванная раскладка, повтори применение». Любая `linking`, видимая под
`w.mu`, устарела по построению (активная раскладка держит `w.mu` весь свой
срок), значит осталась после краха.
## Capabilities
### Modified Capabilities
- `file-layout`: раскладка становится устойчивой к сбою записи claim/учёта —
хардлинки не создаются при незакоммиченном claim, а сбой записи учёта не
стрэндит задачу в `linking`.
- `state-reconciliation`: у нетерминального `linking` появляется владелец на
рестарте/тике — sweep осиротевших `linking` в `review`.
## Impact
- **Код:** `internal/worker/worker.go` (`transition`/`transitionErr`, `pollOnce`,
`sweepLinking`), `internal/worker/review.go` (`Apply`, `finishRecognition`,
`linkPlan`). Граф переходов (`internal/store/download.go`) правки не требует —
ребро `linking → review` уже объявлено.
- **Тесты:** провал claim прерывает до хардлинков; провал `CreateFileLinks`
уводит в `review` (файлы на диске); sweep осиротевшего `linking``review`.
- **БД/схема:** без изменений.
@@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Claim раскладки коммитится до хардлинков и устойчив к сбою учёта
Раскладка — «claim-then-side-effect»: система SHALL сперва зафиксировать переход
задачи в `linking` (claim шага раскладки), и только затем создавать хардлинки.
Если запись claim перехода в `linking` провалилась, система НЕ SHALL создавать
хардлинки и SHALL прервать раскладку, оставив задачу в исходном состоянии
(`review`/`deferred` при ручном применении; `recognizing` при авто-раскладке) —
чтобы у шага сохранился владелец, а хардлинки не легли при незакоммиченном claim
(иначе финальный переход `linking → done` из фактического состояния был бы
отклонён графом, и задача застряла бы со stale-планом).
Если хардлинки уже созданы, но запись их учёта (`file_link`) провалилась
(транзиентная ошибка хранилища), задача НЕ SHALL оставаться в `linking`: система
SHALL перевести её в `review` с причиной. Повторное применение SHALL быть
идемпотентным — уже созданные хардлинки распознаются как существующие
(`StatusExists`), а их учёт дописывается.
#### Scenario: Провал claim не создаёт хардлинков
- **GIVEN** задача в `review` с готовым источником и валидным планом
- **WHEN** запись перехода в `linking` проваливается
- **THEN** хардлинки не создаются, учёт `file_link` не пишется
- **AND** задача остаётся в `review`, а команда отказывает с ошибкой
#### Scenario: Провал учёта уводит в review, не оставляя в linking
- **GIVEN** хардлинки по плану уже созданы на файловой системе
- **WHEN** запись строк `file_link` проваливается транзиентной ошибкой
- **THEN** задача переходит в `review` с причиной (код `persist`), а не остаётся
в `linking`
- **AND** созданные хардлинки остаются на диске
- **AND** повторное «Применить» идемпотентно дописывает учёт и доводит до `done`
@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Восстановление задачи, застрявшей в linking
Система SHALL на каждом тике поллинга и при старте выявлять задачи в состоянии
`linking` и возвращать их в `review` с причиной «прерванная раскладка» (код
`interrupted`), откуда человек повторит применение (повтор идемпотентен).
`linking` — нетерминальное активное состояние, и у него, как у каждого
нетерминального состояния, ДОЛЖЕН быть владелец, продвигающий задачу; иначе
краш процесса между переходом в `linking` и финальным переходом оставил бы
задачу без владельца — её не листит ни один штатный шаг (ни поллинг активных,
ни распознавание, ни матрица сверки, ни восстановление `failed`/`stuck`).
Выявление SHALL выполняться под той же блокировкой переходов, что и раскладка:
активная раскладка удерживает блокировку весь свой срок и завершает переход из
`linking` до её отпускания, поэтому любая `linking`-задача, наблюдаемая под
блокировкой, по построению устарела (осталась после краха) — восстановление НЕ
SHALL задевать раскладку в полёте.
#### Scenario: Осиротевший linking возвращается в review
- **GIVEN** задача осталась в `linking` после краха между claim и финальным
переходом
- **WHEN** выполняется тик поллинга (или старт сервиса)
- **THEN** задача переходит в `review` с причиной «прерванная раскладка»
(код `interrupted`)
- **AND** её можно повторно применить из ревью
#### Scenario: Прочие состояния sweep не задевает
- **GIVEN** задачи в состояниях `done` и `review`
- **WHEN** выполняется тик поллинга
- **THEN** восстановление `linking` их состояние не меняет
@@ -0,0 +1,34 @@
## 1. Возврат ошибки перехода (MINOR-7)
- [x] 1.1 Разделить `transition` на `void`-обёртку и `transitionErr`
(возвращает ошибку записи); пинги/скан — в `transitionErr`
- [x] 1.2 `Apply`: заменить claim `transition(StateLinking)` на
`transitionErr` с прерыванием до `linkPlan` при ошибке
- [x] 1.3 `finishRecognition` (авто-раскладка): то же — при провале claim
остаёмся в `recognizing`, `linkPlan` не зовём
## 2. Провал учёта не оставляет в linking (MAJOR-4 A)
- [x] 2.1 В `linkPlan` при провале `CreateFileLinks` перевести задачу в
`review` (код `persist`) вместо голого `return`
## 3. Sweep осиротевшего linking (MAJOR-4 B)
- [x] 3.1 Добавить `sweepLinking`: под `w.mu` листить `linking` и переводить
`linking → review` (код `interrupted`, причина «прерванная раскладка,
повтори применение»)
- [x] 3.2 Вызвать `sweepLinking` в `pollOnce` (тик + старт)
## 4. Тесты
- [x] 4.1 Провал claim перехода в `linking` прерывает `Apply` до хардлинков
(нет файлов на диске, нет `file_link`, состояние `review`)
- [x] 4.2 Провал `CreateFileLinks` уводит в `review` (файлы на диске есть,
код `persist`)
- [x] 4.3 `sweepLinking` переводит осиротевший `linking` в `review`
(код `interrupted`); прочие состояния не задевает
## 5. Проверки
- [x] 5.1 `task test` и `task lint` проходят
- [x] 5.2 `openspec validate linking-transition-robustness --strict` проходит