web-ui: карточка и страница обновляются, пока задачу может двигать фон

- условие самообновления — доменный предикат store.State.IsObservable() вместо
  фазы catched; один поллер на поверхность, интервалы 5 с и 15 с
- отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели
  вместо 404/500, который htmx не свопит
- заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел
  «Живой поллинг» в конвенции веб-UI
This commit is contained in:
av
2026-08-10 14:02:38 +03:00
parent 969926fae3
commit a5d873b62d
29 changed files with 1575 additions and 99 deletions
@@ -0,0 +1,56 @@
# Наблюдаемость поверхности не выводится из терминальности задачи
- **Дата:** 2026-08-10
- **Источник:** openspec/changes/archive/2026-08-10-card-live-refresh/design.md
## Решение
Веб-UI обновляет себя, пока задача **наблюдаема** — то есть её состояние ещё
может измениться без участия человека, — а не пока она нетерминальна.
Предикат `store.State.IsObservable()` живёт в домене рядом с `IsTerminal()` и
даёт: все нетерминальные плюс `failed`, `target_missing`, `orphaned`. Замолкают
`done`, `cancelled`, `reverted`, `deleted`.
## Почему
Очевидный предикат — «обновляемся, пока задача не терминальна» — оказался
неверным, и это выяснилось на ревью дизайна, до кода. Цитата из источника:
> Терминальность в проекте значит «не активна», а не «навсегда»: фоновая сверка
> двигает часть терминальных сама — `ListRecoverable` возвращает в поток
> `failed`/`stuck` с кодами `magnet_timeout` и `stalled`, а `desyncStates`
> переоценивает `done`, `target_missing` и `orphaned`. Карточка, застывшая по
> `IsTerminal`, показывала бы «Ошибка» у задачи, которая уже качается, — ровно
> тот дефект, ради которого затеян change.
`done` в перечень наблюдаемых не вошёл, и это отдельное решение с ценой:
> Переход `done → target_missing`/`orphaned` означает, что файлы удалили руками
> мимо сервиса, — событие редкое, а карточек `done` в списке больше всех.
> Платить за редкий случай постоянным фоновым запросом на каждую разложенную
> задачу дороже, чем показать её новое состояние при следующем заходе.
## Рассмотренные варианты
- **Наблюдать только нетерминальные** (как задумывалось изначально) — проще
всего и не заводит второго предиката. Отвергнут: задача, оживлённая сверкой из
`failed`, висела бы на экране с надписью «Ошибка» до перезагрузки, причём
соседние карточки при этом обновлялись бы — застывшая читалась бы как
достоверная.
- **Наблюдать всё, терминальные — редким тиком** — снимает вопрос целиком.
Отвергнут: список из сотни разложенных задач слал бы пустые запросы вечно, а
критерий приёмки «завершённая карточка себя не опрашивает» пришлось бы
отменить.
## Последствия
- `+` смена состояния становится видимой независимо от того, кто её сделал:
воркер, веб-UI, Telegram или фоновая сверка.
- `+` условие обновления выражено одним доменным предикатом; второго перечня
состояний в транспорте нет, и завести его нельзя не заметив.
- `` в домене стало два перечня состояний вместо одного, и второй выведен из
поведения воркера (`desyncStates`, `ListRecoverable`) вручную. Расширение
сверки новым состоянием молча вернёт застывшую карточку — связки, которая бы
это ловила, нет.
- `` карточка `failed`, `target_missing` или `orphaned` опрашивает сервер, пока
открыта вкладка: эти состояния живут долго и копятся (срока хранения нет).