Жизненный цикл: Defer запрещён из пре-источникового catched (MAJOR-6)

Команда Defer гардила только IsTerminal() и потому принимала catched
(торрент ещё не добавлен в qBittorrent). Defer из catched уводил задачу
в лимбо → необратимый deleted: processCatched листает только catched и
больше её не подхватывал, а последующие команды через отсутствие
источника выводили deleted (ноль исходящих рёбер), хотя байты .torrent
лежат в download_torrent.

- Worker.Defer отклоняет catched с ErrConflict (транслируется в 409 /
  редирект с сообщением); прочие не-терминальные состояния, где раздача
  уже есть, принимает как раньше.
- Снято мёртвое ребро графа catched → deferred (allowedTransitions);
  инвариант «deferred из каждого не-терминального» уточнён: кроме
  пре-источникового catched. catched — единственное состояние без
  раздачи среди не-терминальных.
- Тесты: Defer из catched отклоняется и не меняет состояние; инвариант
  графа обновлён + негативная проверка ребра.
- OpenSpec: MODIFIED «Команды ревью и их эффекты» (review) с позитивным
  и негативным сценариями; change заархивирован, дельта влита в спеку.
- Беклог: закрыта review-major6-defer-catched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-17 21:39:01 +03:00
co-authored by Claude Opus 4.8
parent 0354a8c96b
commit 098695011f
12 changed files with 307 additions and 23 deletions
+14 -7
View File
@@ -74,18 +74,25 @@ func TestTransitionGraphWellFormed(t *testing.T) {
}
}
// Инвариант Defer: deferred — легальная цель из КАЖДОГО не-терминального
// состояния (кроме самого deferred — это самопереход). Ловит класс дыры «забыли
// состояние» (напр. linking после краха процесса).
func TestDeferReachableFromEveryNonTerminal(t *testing.T) {
// Инвариант Defer: deferred — легальная цель из каждого не-терминального
// состояния, КРОМЕ пре-источникового catched (Defer его отклоняет: раздачи в
// qBittorrent ещё нет, откладывать нечего — MAJOR-6) и самого deferred (это
// самопереход). Ловит класс дыры «забыли состояние» (напр. linking после краха
// процесса).
func TestDeferReachableFromEveryNonTerminalButCatched(t *testing.T) {
for _, s := range allStates {
if s.IsTerminal() || s == StateDeferred {
continue // deferred → deferred покрыт самопереходом
if s.IsTerminal() || s == StateDeferred || s == StateCatched {
continue // deferred → deferred покрыт самопереходом; catched исключён
}
if !slices.Contains(transitionSources[StateDeferred], s) {
t.Errorf("%s → deferred не легально (Defer допускает любое не-терминальное)", s)
t.Errorf("%s → deferred не легально (Defer допускает любое не-терминальное, кроме catched)", s)
}
}
// Пре-источниковое catched → deferred не легально: ребро снято из графа
// заодно с гардом в Worker.Defer.
if slices.Contains(transitionSources[StateDeferred], StateCatched) {
t.Errorf("catched → deferred легально, но должно быть снято (Defer отклоняет catched)")
}
}
// Инвариант универсального стоп-крана Dismiss: cancelled — легальная цель из