Жизненный цикл: 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:
@@ -78,9 +78,10 @@ func (s State) IsTerminal() bool {
|
||||
// (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в
|
||||
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
|
||||
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
|
||||
// - deferred — легальная цель из КАЖДОГО не-терминального состояния (Defer
|
||||
// проверяет лишь IsTerminal); инвариант закреплён тестом, а не ручной
|
||||
// аккуратностью.
|
||||
// - deferred — легальная цель из каждого не-терминального состояния, КРОМЕ
|
||||
// пре-источникового catched (Defer его отклоняет: раздачи в qBittorrent ещё
|
||||
// нет, откладывать нечего, а catched → deferred увёл бы задачу в лимбо —
|
||||
// MAJOR-6). Инвариант закреплён тестом, а не ручной аккуратностью.
|
||||
// - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо
|
||||
// Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный
|
||||
// и из терминальных (done/failed/reverted/target_missing/orphaned) — только
|
||||
@@ -90,7 +91,7 @@ func (s State) IsTerminal() bool {
|
||||
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
|
||||
// setState отклонит переход (0 строк UPDATE → ошибка).
|
||||
var allowedTransitions = map[State][]State{
|
||||
StateCatched: {StateDownloading, StateFailed, StateCancelled, StateDeferred},
|
||||
StateCatched: {StateDownloading, StateFailed, StateCancelled},
|
||||
StateDownloading: {StateCompleted, StateFailed, StateStuck, StateCancelled, StateDeferred},
|
||||
StateCompleted: {StateRecognizing, StateCancelled, StateDeferred},
|
||||
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
|
||||
|
||||
@@ -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 — легальная цель из
|
||||
|
||||
Reference in New Issue
Block a user