Стабилизация, итерация 3: клиентский FSM run-lifecycle вместо 26 ref-флагов (один PR, 5 коммитов) #488

Open
opened 2026-07-11 02:02:45 +03:00 by agent_vscode · 0 comments
Collaborator

Один 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:

  • таблица «событие × состояние» (SDK-колбэки/HTTP-исходы/409-коды → типизированные события);
  • карта всех 26 ref'ов → {состояние автомата | контекст автомата | остаётся данными};
  • протокол run-факта; список инвариантов.
    Решения (выработаны и проверены на ревью, менять только с аргументами):
  1. Автомат живёт в ТРЕДЕ (не в окне): производители событий (SDK-колбэки, transport-fetch) и владелец состояния в одном lifetime — поздний колбэк умирает вместе с владельцем (конструктивно убивает класс «событие от мёртвого view», #161 и родня). Poll-query переносится в тред (queryKey общий, TanStack-кэш один, disable-on-unmount глушит poll; контракт onResumeFallback через границу компонентов исчезает). У окна остаются geometry/identity/вызов stopRun.
  2. Инвариант №1 — epoch (generation counter): команды автомата (resumeStream(), stop(), POST) асинхронны, исходы приходят теми же колбэками; каждый переход-с-командой инкрементит epoch, каждое событие несёт epoch, редьюсер отбрасывает чужие. Без этого — тот же one-shot-ref-зоопарк в новых координатах.
  3. Состояния: idle | sending | streaming | attaching | reconnecting(attempt) | polling(reason) | stalled | stopping | superseding | error(kind). ownership: 'local' | 'observer' — ПОЛЕ контекста, не состояние (наблюдение ортогонально транспортным фазам; иначе произведение состояний взрывает таблицу). Инвариант «очередь не флашится наблюдателем» гейтится по ownership.
  4. Выход из stopping — по данным, не по HTTP: stopRun отвечает после abort, ДО финализации строки — выход по терминальной строке/run-факту (иначе композер разблокируется в 409).
  5. Run-факт («ран активен») — first-class с сервера: init — POST /run на маунте; обновления — stream-метаданные start (runId), исходы attach (2xx/204), поллинг (после итерации resume-стека — поле run в дельте). Свежий отрицательный факт (attach 204 на нестрипнутом пути, /run → null/терминал) гасит reconnecting немедленно; stale-факт разрешает вход (пессимизм в сторону попытки, 204 отсечёт).
  6. Dispose-протокол: command-контроллеры (attach GET, POST /stream, POST /run) — собственность эффектов автомата с abort в cleanup, НЕ render-phase refs. Правило: клиентский abort уже отправленного POST не отменяет серверное действие — дизарм на unmount безопасен. Режимные следствия — строками таблицы (autonomous: брошенный ход продолжается detached; legacy: разрыв = requestStop → обрубок 'aborted' — легальная семантика).
  7. Эффекты attaching/reconnecting — за интерфейсом attachStrategy (текущая реализация — full-replay+strip; итерация resume-стека подменит на tail-only, автомат не меняется).
  8. Очередь остаётся структурой данных; решения о flush — переходы автомата (flushOnAbort/interruptNext — контекст перехода).

Коммит 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-фильтрация позднего колбэка отменённой попытки.

Один 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: - таблица «событие × состояние» (SDK-колбэки/HTTP-исходы/409-коды → типизированные события); - карта всех 26 ref'ов → {состояние автомата | контекст автомата | остаётся данными}; - протокол run-факта; список инвариантов. Решения (выработаны и проверены на ревью, менять только с аргументами): 1. **Автомат живёт в ТРЕДЕ** (не в окне): производители событий (SDK-колбэки, transport-fetch) и владелец состояния в одном lifetime — поздний колбэк умирает вместе с владельцем (конструктивно убивает класс «событие от мёртвого view», #161 и родня). Poll-query переносится в тред (queryKey общий, TanStack-кэш один, disable-on-unmount глушит poll; контракт `onResumeFallback` через границу компонентов исчезает). У окна остаются geometry/identity/вызов stopRun. 2. **Инвариант №1 — epoch (generation counter):** команды автомата (`resumeStream()`, `stop()`, POST) асинхронны, исходы приходят теми же колбэками; каждый переход-с-командой инкрементит epoch, каждое событие несёт epoch, редьюсер отбрасывает чужие. Без этого — тот же one-shot-ref-зоопарк в новых координатах. 3. **Состояния:** `idle | sending | streaming | attaching | reconnecting(attempt) | polling(reason) | stalled | stopping | superseding | error(kind)`. **`ownership: 'local' | 'observer'` — ПОЛЕ контекста, не состояние** (наблюдение ортогонально транспортным фазам; иначе произведение состояний взрывает таблицу). Инвариант «очередь не флашится наблюдателем» гейтится по ownership. 4. **Выход из `stopping` — по данным, не по HTTP:** stopRun отвечает после abort, ДО финализации строки — выход по терминальной строке/run-факту (иначе композер разблокируется в 409). 5. **Run-факт («ран активен») — first-class с сервера:** init — POST /run на маунте; обновления — stream-метаданные `start` (runId), исходы attach (2xx/204), поллинг (после итерации resume-стека — поле run в дельте). Свежий отрицательный факт (attach 204 на нестрипнутом пути, /run → null/терминал) гасит reconnecting немедленно; stale-факт разрешает вход (пессимизм в сторону попытки, 204 отсечёт). 6. **Dispose-протокол:** command-контроллеры (attach GET, POST /stream, POST /run) — собственность эффектов автомата с abort в cleanup, НЕ render-phase refs. Правило: клиентский abort уже отправленного POST не отменяет серверное действие — дизарм на unmount безопасен. Режимные следствия — строками таблицы (autonomous: брошенный ход продолжается detached; legacy: разрыв = requestStop → обрубок 'aborted' — легальная семантика). 7. Эффекты attaching/reconnecting — за интерфейсом **attachStrategy** (текущая реализация — full-replay+strip; итерация resume-стека подменит на tail-only, автомат не меняется). 8. Очередь остаётся структурой данных; решения о flush — переходы автомата (flushOnAbort/interruptNext — контекст перехода). ### Коммит 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-фильтрация позднего колбэка отменённой попытки.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#488