Стабилизация, итерация 3: клиентский FSM run-lifecycle вместо 26 ref-флагов (один PR, 5 коммитов) #488
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Один PR, коммиты в порядке ниже. Зависит от #487 (runId в start-метаданных обоих режимов, новые 409-коды, серверный supersede). Контекст: машина состояний resume/reconnect размазана по 26
useRefвchat-thread.tsx(1246 строк) с one-shot-флагами и «дисармами на всех путях»; комбинации без владельца дают молчаливые замерзания UI; каждый фикс добавлял новые refs (спираль #381→#432→#456).Процессное правило после мержа: новые ref-флаги в chat-thread.tsx запрещены; новое поведение = новое состояние/переход автомата с unit-тестом перехода.
Коммит 1 — refactor(client): каркас автомата + приложение-спека
Приложение (таблицы) пишется ПЕРВЫМ и входит в PR:
Решения (выработаны и проверены на ревью, менять только с аргументами):
onResumeFallbackчерез границу компонентов исчезает). У окна остаются geometry/identity/вызов stopRun.resumeStream(),stop(), POST) асинхронны, исходы приходят теми же колбэками; каждый переход-с-командой инкрементит epoch, каждое событие несёт epoch, редьюсер отбрасывает чужие. Без этого — тот же one-shot-ref-зоопарк в новых координатах.idle | sending | streaming | attaching | reconnecting(attempt) | polling(reason) | stalled | stopping | superseding | error(kind).ownership: 'local' | 'observer'— ПОЛЕ контекста, не состояние (наблюдение ортогонально транспортным фазам; иначе произведение состояний взрывает таблицу). Инвариант «очередь не флашится наблюдателем» гейтится по ownership.stopping— по данным, не по HTTP: stopRun отвечает после abort, ДО финализации строки — выход по терминальной строке/run-факту (иначе композер разблокируется в 409).start(runId), исходы attach (2xx/204), поллинг (после итерации resume-стека — поле run в дельте). Свежий отрицательный факт (attach 204 на нестрипнутом пути, /run → null/терминал) гасит reconnecting немедленно; stale-факт разрешает вход (пессимизм в сторону попытки, 204 отсечёт).Коммит 2 — fix(client): обрыв SSE до первого кадра ассистента
chat-thread.tsx:602-608:startedReconnectтребуетmessage?.role === "assistant"— обрыв в setup-фазе (включая сборку MCP-тулсета с дедлайном до 60 c!) → ни реконнекта, ни поллинга, а detached-ран продолжает писать в страницы. Правка: вход в реконнект по run-факту, не по наличию assistant-message. Тест: «обрыв до первого кадра → ран подхвачен».Коммит 3 — fix(client): второй обрыв после успешного re-attach
Лестница 5 попыток с backoff уже есть; одноразовость — в условии
!wasResumed: после успешного re-attach ход помечен resumed, и ВТОРОЙ обрыв уходит в тихий poll. Правка: переход attached→reconnecting разрешён многократно (сброс счётчика после успешного attach). Тест: «два обрыва подряд → два цикла реконнекта».Коммит 4 — fix(client): поллинг — stalled-состояние и user-tail
(а) Сейчас по истечении idle-капа 10 мин
refetchInterval → falseи ТИШИНА (ai-chat-window.tsx:291-295): баннера нет, Retry нет — «вечно полуготовый ответ». Правка: переход polling→stalled с баннером и Retry. (б)attemptResumeRefистинен и для user-tail (chat-thread.tsx:256-260): чат, оборвавшийся на сообщении юзера, при КАЖДОМ открытии армит бессмысленный поллинг (~240 запросов/10 мин). Правка: армить только при серверном подтверждении активного рана. Тесты: unit перехода stalled; «user-tail без активного рана не порождает поллинг».Коммит 5 — fix(client): классификация ошибок и композер
error-message.tsне имеет веток 409 — юзер видит сырой JSON{"code":"A_RUN_ALREADY_ACTIVE"}. Правка: ветки дляA_RUN_ALREADY_ACTIVE,SUPERSEDE_TIMEOUT,SUPERSEDE_TARGET_MISMATCH,A_RUN_BEGIN_FAILED— человеческие тексты, СТРОГО ДО generic-матчей по статусу (generic-503 иначе перехватит); композер в polling/reconnecting/attached предлагает «прервать и отправить» (supersede из #487) вместо слепого POST; MISMATCH после собственного supersede → сверка через /run, не баннер; наблюдатель superseded-рана: переход «ран aborted (superseded) → запросить latest run → следовать за новым» (иначе таб-наблюдатель замерзает на убитом ране). Тесты: классификатор; UI композера в polling; наблюдатель superseded.Тесты каркаса: таблица переходов юнитами (впервые перечислима); epoch-фильтрация позднего колбэка отменённой попытки.