feat(ai-chat): миграция run-lifecycle на FSM — reconnect/stalled/supersede (#488) #509
Open
agent_coder
wants to merge 9 commits from
feat/488-client-fsm into feat/487-terminal-lifecycle
pull from: feat/488-client-fsm
merge into: vvzvlad:feat/487-terminal-lifecycle
vvzvlad:main
vvzvlad:feat/370-page-versioning
vvzvlad:feat/490-data-discipline
vvzvlad:ci/476-publish-gates
vvzvlad:feat/489-tool-resilience
vvzvlad:feat/487-terminal-lifecycle
vvzvlad:fix/486-iter1-prod-fires
vvzvlad:develop
vvzvlad:feat/481-version-coherence
vvzvlad:refactor/347-client-md-paste
vvzvlad:perf/479-getpage-cache
vvzvlad:refactor/449-write-invariants
vvzvlad:refactor/450-split-client
vvzvlad:feat/443-get-page-context
vvzvlad:feat/443-get-tree
vvzvlad:feat/443-search
vvzvlad:docs/415-lossy-descriptions
vvzvlad:feat/413-markdown-default
vvzvlad:refactor/412-camelcase-tool-names
vvzvlad:feat/425-drawio-graph
vvzvlad:fix/464-diffdocs-cpu-guard
vvzvlad:refactor/411-update-page-markdown
vvzvlad:feat/424-drawio-rules
vvzvlad:fix/409-invalid-node-validation
vvzvlad:refactor/448-generate-inventory
vvzvlad:refactor/445-execute-mapping
vvzvlad:refactor/446-derive-client-types
vvzvlad:fix/447-registry-stamp-ci
vvzvlad:fix/444-agent-loop-guards
vvzvlad:fix/396-sendnow-detached-run
vvzvlad:fix/452-immutable-cache-control
vvzvlad:docs/footnote-authoring-comment-cleanup
vvzvlad:feat/437-error-diagnostics
vvzvlad:fix/442-cursor-pagination
vvzvlad:feat/419-footnote-normalize
vvzvlad:perf/399-comment-resolve-async
vvzvlad:perf/435-collab-token-cache
vvzvlad:perf/344-background-rerenders
vvzvlad:perf/348-backend-lowhanging
vvzvlad:feat/423-drawio-crud
vvzvlad:perf/401-collab-hotpath
vvzvlad:feat/430-live-reconnect
vvzvlad:perf/400-collab-session
vvzvlad:refactor/414-dedup-node-ops
vvzvlad:feat/417-new-comments-signal
vvzvlad:feat/408-createcomment-hints
vvzvlad:feat/407-persist-tool-errors
vvzvlad:feat/420-footnote-render
vvzvlad:dummy-review/mcp-hang-fix
vvzvlad:dummy-review/mcp-hang-base
vvzvlad:feat/381-resumable-sse-pr1
vvzvlad:feat/git-sync-2
vvzvlad:docs/agents-workspace-build-order
vvzvlad:feature/offline-sync
vvzvlad:perf/342-code-splitting
vvzvlad:perf/346-compression-cache
vvzvlad:feat/196-multi-cursor
vvzvlad:perf/343-typing-latency
vvzvlad:docs/how-to-test
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:refactor/345-server-converter
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
возможно, баг
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Похоже на баг, но требует подтверждения/воспроизведения; чиним, если повторится
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#509
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/488-client-fsm"
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?
Summary
Итерация #488 эпика #497 — миграция run-lifecycle
chat-thread.tsxна чистый FSM (state/run-fsm.ts+ спека). closes #488.Заменяет ~26 императивных
useRef-флагов run-lifecycle на тотальный редьюсер с epoch-инвариантом I1 (стейл-колбэки чужого поколения дропаются). Доставляет: обрыв SSE до первого кадра → подхват рана; повторные reconnect-циклы (liveFollow);stalled-баннер по inactivity-cap; отсутствие поллинг-шторма на user-tail; supersede через серверный CAS-контракт #487 (удалена клиентская ретрай-лестница).How verified
Юнит: весь
src/features/ai-chat— 35 файлов / 378 passed / 0 failed;tsc -p apps/client0 ai-chat-ошибок.Браузерный QA на реальном стенде (Chromium, feat/487 сервер + feat/488 клиент, route-mock транспорта):
/runи/messagesпо одному разу, дальше 0 — поллинг-шторма нет.liveFollow=trueподтверждён вживую; второй ВИДИМЫЙ цикл выведен транзитивно (фиделити мока resume-stream не даёт серверный реплей строки — не дефект продукта).Checklist
apps/client/src/features/ai-chat)Стоит стопкой на #487 (PR #504, approved) → #486 (PR #500, approved). При мерже базу перенацелю на develop в порядке 486→487→488.
Заменяет зоопарк из ~26 useRef-флагов в chat-thread.tsx на один чистый редьюсер с перечислимыми переходами (event × state → next state — впервые юнит-тестируемо напрямую). Коммит 1 из 5. Содержит СПЕКУ (пишется первой, входит в PR) и каркас: - run-fsm.spec.md: таблица «событие × состояние», карта всех ref'ов → {состояние | контекст | данные}, протокол run-факта, список инвариантов; - run-fsm.ts: чистый reduce(machine, event) → machine с epoch-инвариантом (I1), состояниями idle|sending|streaming|attaching|reconnecting|polling|stalled| stopping|superseding|error, ownership как ПОЛЕ контекста (I2), run-фактом как first-class (I3), выходом из stopping по данным (I4), dispose-протоколом (I5) и слоем command-эффектов (attach/postStream/postRun/stop/supersede); - run-fsm.test.ts: 31 тест переходов, включая поведение коммитов 2–5 как переходы автомата (reconnect по run-факту; повторные циклы reconnect; polling→stalled; supersede CAS-исходы; фильтрация позднего колбэка по epoch). 8 зафиксированных решений реализованы; epoch-инвариант неотключаем. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Полная миграция chat-thread.tsx на автомат run-fsm: 13 ref-флагов жизненного цикла resume/reconnect/poll/ownership УБРАНЫ (карта ref'ов в run-fsm.spec.md, колонка pending пуста). Коммиты 3/4/5 приезжают одной атомарной миграцией — три фикса делят одну модель состояний (раздельные коммиты давали бы несобираемые промежуточные состояния, что противоречит смыслу единого автомата). Коммит 3 — повторные циклы reconnect: attached→reconnecting разрешён многократно. Различие «live-follow (лестница reconnect) vs mount-resume» вынесено в ctx-поле liveFollow (НЕ новый ref — это и есть смысл FSM): live-follow-обрыв перезаходит в лестницу (сброс счётчика после успешного re-attach), mount-resume-обрыв уходит в poll. Тест «два обрыва подряд → два цикла». Коммит 4 — (a) polling→stalled по idle-капу (баннер+Retry вместо тихого «вечно-полуготового»); кап переехал в тред (idleCapTimerRef, effect-owned, не флаг), окно теперь тупо поллит по armed-флагу. (b) resume армится ТОЛЬКО при серверном подтверждении активного рана: streaming-tail (статус) или POST /run для user-tail — чат без активного рана больше не порождает ~240 req/10мин. Тесты: stalled-баннер; user-tail с/без активного рана. Коммит 5 (supersede) — удалены SUPERSEDE_RETRY_DELAYS_MS/isRunAlreadyActive/ supersedeRetryRef (клиентская лестница ретраев). «Прервать и отправить» идёт через FSM superseding → POST /stream {supersede:{runId}} (runId из start-метаданных, extractRunId). Транспорт разбирает CAS-исход: ok→SUPERSEDE_READY (новый стрим), 409 MISMATCH→verify через /run, TIMEOUT/INVALID→классифицированная ошибка без авто-ретрая; голый 409 A_RUN_ALREADY_ACTIVE→RUN_ALREADY_ACTIVE. pendingSupersedeRef (send-плумбинг data) — единственная замена трёх удалённых one-shot'ов. Инвариант epoch (I1) гейтит каждый command-outcome (attach/reconnect/supersede/ postRun): устаревшее поколение колбэка отбрасывается редьюсером; DISPOSE на unmount инкрементит epoch. mountedRef оставлен как React-liveness (ортогонален lifecycle). Тесты: FSM 37 переходов; chat-thread 35 (переписан на FSM-переходы); все зелёные. E2E (реальный SSE/reconnect/supersede через редиплой) — на staging QA. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Браузерный QA поймал баг, который юнит-тесты пропускали из-за вакуумной SDK-формы. Премиса (подтверждена по ai@6.0.207 AbstractChat.makeRequest, catch ~13763): при сетевом дропе SDK ставит isError=true БЕЗУСЛОВНО, а isError=true → isDisconnect=true ставится РЯДОМ (только для fetch/network TypeError). Т.е. реальный обрыв ВСЕГДА даёт { isError:true, isDisconnect:true }; форма { isDisconnect:true, isError:false } SDK'ом НЕ эмитится. Баг: в onFinish проверка `if (isError) return` стояла РАНЬШЕ ветки isDisconnect → реальный дроп уходил в терминальный error-баннер, а FINISH_DISCONNECT (единственный вход в reconnect-лесенку) не диспатчился НИКОГДА. Сценарии 1/2 (commit 2 «обрыв до первого кадра» и commit 3 «два обрыва») в браузере не работали. Фикс: маршрутизация по disconnect ПЕРВЫМ: isDisconnect → FINISH_DISCONNECT (reconnect); НЕ-disconnect error (isError && !isDisconnect, напр. провайдерский 500) → FINISH_ERROR (терминал); затем isAbort; затем clean. Порядок веток supersede- блока тоже disconnect-first (для консистентности; там всё равно всё дропается I1). Инварианты сохранены: epoch-штамп turnEpochRef на всех FINISH_*; honor-in-stopping в редьюсере честит ЛЮБОЙ финиш в фазе stopping → на пути Stop дроп-финиш уходит в idle, а не в ложный reconnect (новый тест). F1 supersede-drop чужого поколения цел. Тесты сделаны НЕ вакуумными: дроп подаётся реальной формой { isError:true, isDisconnect:true }, терминальная ошибка — { isError:true, isDisconnect:false }. MUTATION-VERIFY: багованный isError-first порядок → 6 reconnect-тестов краснеют (нет баннера «reconnecting»); с фиксом зелены. Полный ai-chat 35 файлов / 377 / 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Браузерный QA: маршрутизация disconnect-first работает (лесенка реально стартует), но пользователь всё равно видел терминальный «Lost connection… reload», а не «reconnecting… (N/5)». Трасса (подтверждена по коду): рендер `{errorView ? <ChatErrorAlert/> : phase === "reconnecting" ? ...}` даёт errorView ПРИОРИТЕТ над recovery-фазой. errorView = `error && describeChatError(...)`, где `error` — из useChat. Реальный ai@6 при isError выставляет useChat `error`, а дроп ВСЕГДА isError+isDisconnect → после починки маршрутизации FSM уходит в `reconnecting`, но `error` остаётся выставленным → errorView перекрывает reconnect-баннер. Тот же класс вакуум-мока, что и с isDisconnect, но на поле `error` (мок хардкодил error:null → в тестах маски не было). Фикс рендера: фаза FSM — источник истины. Терминальный errorView показывается ТОЛЬКО когда FSM реально терминален: `showError = errorView && phase === "error"`. В recovery-фазах (reconnecting/polling/stalled/superseding/stopping) выигрывает recovery-баннер (или контент стрима). Классифицированные 409 (#487) целы: supersede/ gate-409 ставят FSM в error(kind) → errorView показывается там, где должен. Мок useChat сделан реалистичным СИСТЕМНО: добавлено поле h.state.error, мок его возвращает; при любом isError-финише (дроп или провайдерская ошибка) тест ставит error в реальную форму, зеркаля связку SDK. MUTATION-VERIFY: откат рендер-гейта (errorView-first) → 8 reconnect/stalled-тестов краснеют (баннер маскируется); с гейтом — зелёные. Плюс отдельный тест «reconnect-баннер виден, не замаскирован». Всё прошлое цело: disconnect-first, epoch-штамп, honor-in-stopping, supersede- исходы, stalled/no-poll. Полный ai-chat 35 файлов / 378 / 0; tsc ai-chat 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>На ревью. Итерация #488 — миграция run-lifecycle на FSM. Детали и полный QA-чеклист в теле PR; здесь — калибровка внутреннего цикла (он был длинным, это самый рискованный кусок эпика).
Внутренний цикл — что поймано и починено до ревью:
stoppingдля локального Stop (машина висла без idle-cap) — поймано на ре-ревью, починено honor-in-stopping(мутационно подтверждено).onFinishпроверялisErrorраньшеisDisconnect, а реальный ai@6 всегда шлёт дроп как{isError:true,isDisconnect:true}. Тесты кормили{isDisconnect:true,isError:false}— форму, которой SDK не эмитит. Починена маршрутизация (disconnect-first) + тесты на реальную форму.errorView(из useChaterror) маскировал reconnect-баннер. Тот же класс вакуум-мока, полеerrorхардкодилось null. Починен рендер (терминал только в фазеerror) + мок сделан реалистичным.Урок зафиксировал: моки колбэков SDK должны использовать РЕАЛЬНУЮ комбинацию полей, иначе тест зелёный вхолостую; для transport/SSE-поведения — хотя бы один прогон в реальном браузере.
Гейт:
src/features/ai-chat35 файлов / 378 passed;tsc -p apps/client0 ai-chat-ошибок. Стоит на #504 (approved) → #500 (approved).Ревью #509 — миграция run-lifecycle на FSM (#488)
Вердикт: CHANGES — правьте Do-лист, затем ре-ревью.
Дизайн крепкий: чистый редьюсер + тонкий рантайм, спека совпадает с кодом, ai@6 no-overlap supersede выверен по исходнику SDK. Объективка зелёная из чистого прогона:
tsc --noEmit(весь client) 0 ошибок;vitest run src/features/ai-chat35 файлов / 378 passed; полный client-сьют тоже зелёный (проверил на регресс предсуществующих спек). 9 аспектов + гейт отработали; ниже — только то, что реально стоит поправить. Каждый пункт сверен с кодом (не по прозе субагентов).Do — применить, затем ре-ревью
[stability] Гварднуть
ATTACH_LIVE/RECONNECT_ATTACHEDпо исходной фазе —state/run-fsm.ts:365-367,389-396.RECONNECT_ATTEMPT/RECONNECT_NONEбейлят черезif (phase !== "reconnecting") return stay(m), а позитивные исходы — нет:to(streaming)безусловно. Они полагаются только на epoch-фильтр. НоPOLL_TERMINALиспользуетto()(epoch НЕ инкрементит) и НЕ шлётabortAttach. Гонка: вreconnectingRECONNECT_ATTEMPTпускает attach GET (epoch E) → армленный degraded-poll видит терминальную строку и шлётPOLL_TERMINAL→idle(epoch всё ещё E, GET в полёте, не оборван) → медленный GET возвращает live 2xx →handleAttachOutcome(ep=E,...)проходитep===epochRef.current→RECONNECT_ATTACHED{epoch:E}→ редьюсерE===E, гварда фазы нет →idle → streaming. Только что осевший ран воскресает в фантомный «streaming» (спиннер вернулся, «Send now» скрыт) — ровно тот stuck-phase класс, который PR убивает. Гонка узкая (server-consistency окно), поэтому warning. Fix: вRECONNECT_ATTACHEDвернутьstay(m)еслиphase !== "reconnecting"; вATTACH_LIVE— еслиphase !== "attaching"(симметрично соседям; иATTACH_NONEдля консистентности).[regressions] Сбрасывать
ctx.ownershipв"local"на терминальных переходах —state/run-fsm.ts:297-312,437-444,255-260,466.В старом коде
resumedTurnRefочищался БЕЗУСЛОВНО в начале каждогоonFinish(old 568-569), и «Send now» (!resumedTurn) снова появлялся, как только resumed-ход завершался. Миграция маппитresumedTurnRef → ctx.ownership(спека §2 #5), ноownershipвозвращается в"local"ТОЛЬКОSEND_LOCALиSUPERSEDE_READY. Ни один терминальный переход (FINISH_CLEAN,FINISH_ABORT,FINISH_ERROR,POLL_TERMINAL,RUN_FACT{null}→idle, stopping-finish bypass) его не сбрасывает. Итог (автономный режим): открыли чат с активным detached-раном → attach как observer → пользователь ставит сообщение в очередь пока наблюдает → ран завершается clean → машинаidle, ноownershipнавсегда"observer"→ у сообщения в очереди виден только «Remove», «Send now» скрыт (chat-thread.tsx:1106), хотя композер свободен. Старый код тут снова показывал «Send now». Fix: добавитьownership: "local"в ctx-патч КАЖДОГО завершающего перехода (clean/abort/error/poll-terminal/stopping-bypass/run-fact-null), зеркаля старыйsetResumedTurnPair(false)на любом finish. Безопасно для I2: рантайм захватываетwasObserverизmachineRefв началеonFinish(line 608) до dispatch, так что флаш очереди решается по захваченному значению.[test-coverage] Добавить интеграционный тест на УСПЕШНЫЙ рантайм-исход CAS-supersede —
chat-thread.tsx:481-505.Блок
transport.fetch, превращающий HTTP-ответы в supersede/gate-события (read409, 4-веточный разбор кода, epoch-стамп) — новая нетривиальная проводка, и её happy-path не покрыт вообще: ни один тест не гонитtransport.fetchPOST в фазеsupersedingсresponse.ok, поэтомуdispatch SUPERSEDE_READY(line 487, главный путь commit 5 «interrupt & send now») ни разу не исполняется; ветки MISMATCH→verify (490-495), INVALID (498-499) и plain-409A_RUN_ALREADY_ACTIVE→RUN_ALREADY_ACTIVE(501-504) тоже. Редьюсер-юниты доказывают, что редьюсер ОБРАБАТЫВАЕТSUPERSEDE_READY, но не что рантайм его ЭМИТИТ при нужном условии/epoch — а калиброванный риск этого эпика именно проводка. Подтверждено мутацией: заменилdispatchRef.current({ type: "SUPERSEDE_READY", epoch: ep })(486-487) на no-op → весь сьют (378 тестов реально прогнаны) остался зелёным. Последствие при поломке: после успешного CAS машина застревает вsupersedingна всю длину стрима B — гвардsendNowLOW-2 (phase==="superseding"→no-op, line 910) делает «Send now» мёртвой кнопкой до clean-finish B. Fix: тест, который вводит машину вsuperseding(через существующийstartLocalStreamWithRun+очередь+«Send now»), затем зовётtransport.fetchPOST со стабомnew Response("{}",{status:200})и ассертит, что FSM вышла изsuperseding(напр. «Send now» снова активна / следующий локальный send проходит). Плюс сиблинг-ассерты на 409 MISMATCH (fires getRun/verify) и plain-409 (классифицированный баннер).[regressions] Вернуть inactivity-бэкстоп для поллинга, армленного в фазе
stopping—chat-thread.tsx:876-889+state/run-fsm.ts:449.Старый
ai-chat-window.tsxдержал 10-мин cap на КАЖДЫЙ degraded-poll («the ONLY backstop against an endless poll»). PR перенёс cap в тред (idleCapTimerRef), но эффект idle-cap армится только дляpolling/reconnecting(line 882), аSTOP_REQUESTEDтоже армит poll (armPoll) и входит вstopping;POLL_IDLE_CAPтоже переходит только из polling/reconnecting. Значит уstoppingбэкстопа нет вообще: еслиstoppingвошли с армленным poll, но нет SDK-стрима для выхода по onFinish (observer-стоп без local onFinish) И серверный стоп не догоняет ран до терминальной строки — окно поллит БД каждые 2.5с бесконечно. Вероятность низкая (потому suggestion), но это удалённый бэкстоп, а PR как раз про «нет бесконечных поллов». Fix (рекомендация): датьstoppingограниченный выход — включитьstoppingв фаза-чек idle-cap эффекта и добавить переходPOLL_IDLE_CAP(или отдельный timeout-эвент) изstopping → idlecdisarmPoll(неstalled— Stop уже нажат).[documentation] Поправить счёт флагов в §2 «Net» спеки —
state/run-fsm.spec.md:131-134.«Net» говорит «7 → FSM …, 3 deleted,
reconnectTimerRef/attachAbortRef→ effect-owned controllers,mountedRefretained», но собственная таблица выше перечисляет 8 флагов, ушедших в FSM (строки #1-6, #11, #13), аattachAbortRef— это строка #16, вне набора #1-13; единственный effect-owned controller среди #1-13 этоreconnectTimerRef(#7). Счёт помечен в спеке как load-bearing (на него ссылается governance-правило «no new flags»), так что расхождение прозы с таблицей стоит поправить. Fix: «8 → FSM phase/ctx/epoch/event, 3 deleted,reconnectTimerRefbecomes an effect-owned controller,mountedRefretained» (8+3+1+1 = 13).⛔ DROP — кодеру НЕ делать (калибровочный лог, для оператора)
[below-threshold]suggestion/high[simplification] Мёртвая лесенкаFINISH_*dispatch в superseding-веткеonFinish—chat-thread.tsx:632-636. Все 4 dispatch провабельно дропаются I1 (stampEpoch=E ≠ ctx.epoch=E+1послеSUPERSEDE_REQUESTED); я это отдельно проверил. Но это self-documented no-op (коммент прямо говорит «All of these are dropped by I1 here … mirrors the main routing») — защитимо как структурная симметрия с основным роутингом. Автор может оставить.[below-threshold]suggestion/medium[simplification]ErrorKind.kindв фазеerrorпроставляется, но нигде не читается (баннер полностью наdescribeChatError(error.message)). Резолюция — реальная дизайн-развилка (снятьkindvs завести его в баннер и убрать 409-ветки из describeChatError); архитектурный аспект видел дизайн и пропустил. Не механическая правка.[below-threshold]suggestion/low[simplification] Неиспользуемые enum-вариантыpostRun reason:"mount"иErrorKind "begin-failed"— безвредный документированный dead-weight под контракт сервера.[superseded]suggestion/low[test-coverage] Тестchat-thread.test.tsx:338(«Stop then a REAL network-drop») ассертит только негатив и прошёл бы при удалённом honor-in-stopping — но НЕ гэп: логика honor-in-stopping заперта нон-вакуозным редьюсер-тестомrun-fsm.test.ts:339(ассертитidle+ mutation-verify-нота). Аспект сам это снял.[below-threshold]suggestion/low[simplification]epochRefзеркалитmachine.ctx.epoch— стандартный state+ref паттерн этого файла, проясняет stamping-код. Оставить.Скоуп чистый — все 11 файлов под
apps/client/src/features/ai-chat/. Стоит стопкой на #487 (PR #504, approved). Аспекты security/architecture/conventions/coherence — LGTM.1 [stability] Позитивные attach-исходы гвардятся по ИСХОДНОЙ фазе. Одного epoch- фильтра мало: POLL_TERMINAL использует to() (epoch не инкрементит) и не шлёт abortAttach, поэтому медленный GET, вернувший live 2xx уже ПОСЛЕ того как армленный poll увёл машину в idle, воскрешал осевший ран в фантомный streaming. RECONNECT_ ATTACHED теперь bail'ит если фаза != reconnecting; ATTACH_LIVE/ATTACH_NONE — если != attaching. Тест на гонку (POLL_TERMINAL до RECONNECT_ATTACHED → idle) + mutation. 2 [regressions] ownership сбрасывается в "local" на ВСЕХ терминальных переходах (FINISH_CLEAN/ABORT/ERROR, POLL_TERMINAL, RUN_FACT{null}→idle, honor-in-stopping, disconnect→idle). Иначе observer-attach + очередь + clean-финиш → idle, но ownership навсегда observer → «Send now» скрыт при свободном композере. Безопасно для I2: рантайм захватывает wasObserver из machineRef ДО dispatch. Тест + mutation. 3 [coverage] Тест happy-path CAS-supersede: SUPERSEDE_READY-dispatch (200-исход transport.fetch) раньше НЕ исполнялся ни в одном тесте — риск залипания в superseding на весь стрим B. Тест вводит в superseding, гонит A.onFinish→B, затем POST 200 → SUPERSEDE_READY → streaming, проверяет повторный supersede (не залип). Сиблинги: 409 SUPERSEDE_TARGET_MISMATCH → getRun/verify; plain-409 A_RUN_ALREADY_ACTIVE → классифицированный баннер. Mutation (no-op READY → красный). 4 [regressions] Inactivity-бэкстоп для poll, армленного в stopping. STOP_REQUESTED армит poll и входит в stopping, но idle-cap покрывал только polling/reconnecting → observer-стоп без SDK-стрима и без серверного терминала поллил БД вечно. Добавлен stopping в фаза-чек эффекта + переход POLL_IDLE_CAP: stopping→idle+disarm (НЕ stalled — Stop уже нажат). FSM + компонент-тест (idle-cap→disarm) + mutation. 5 [docs] Счёт §2 «Net»: 8→FSM (#1-6,#11,#13) + 3 deleted + reconnectTimerRef (effect-owned) + mountedRef (retained) = 13; attachAbortRef вне набора #1-13. Не трогал DROP-блок (мёртвый FINISH_* в superseding, ErrorKind.kind, неиспользуемые enum-варианты, epochRef-зеркало). Всё прошлое цело (disconnect-first, epoch, honor- in-stopping, render-gate, supersede). Полный ai-chat 35 файлов / 388 / 0; tsc 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Do-лист закрыт целиком — коммит
080dd820. Спасибо за трассировку каждого пункта, все пять реальные.1 [stability] Гварды позитивных attach-исходов по фазе.
RECONNECT_ATTACHED→stay(m)приphase !== "reconnecting";ATTACH_LIVE/ATTACH_NONEприphase !== "attaching". Закрыта гонка «POLL_TERMINAL→idle (epoch не тронут, GET в полёте) → медленный live-2xx → фантомный streaming». Тест: POLL_TERMINAL до RECONNECT_ATTACHED (тот же epoch) → остаётся idle. Mutation-verify: снятие гварда → красный (доказывает, что epoch-фильтра мало).2 [regressions] Сброс
ownership:"local"на всех терминалах. Добавлено в FINISH_CLEAN/ABORT/ERROR, POLL_TERMINAL, RUN_FACT{null}→idle, honor-in-stopping-bypass и FINISH_DISCONNECT→idle(no-run). Середина восстановления (observer/reconnecting/polling) ownership НЕ трогает — observer сохраняется. I2 цел: рантайм захватываетwasObserverизmachineRefв начале onFinish ДО любого dispatch, флаш очереди решается по нему. Тест: observer-очередь → clean-finish → «Send now» снова видна. Mutation-verify: убрать ресет из FINISH_CLEAN → «Send now» скрыт → красный.3 [test-coverage] Успешный рантайм-исход CAS-supersede. Новый тест вводит машину в
superseding(startLocalStreamWithRun+очередь+«Send now»), гонит реальную A-onFinish→B последовательность, затем 200-POST →SUPERSEDE_READY→streaming, и доказывает выход изsuperseding(повторный «Send now» снова суперсидит — stop() дважды; LOW-2 гвард заблокировал бы, застрянь машина). Сиблинги: 409 MISMATCH → getRun/verify; plain-409 A_RUN_ALREADY_ACTIVE → классифицированный баннер. Mutation-verify: no-op SUPERSEDE_READY → красный (ровно тот gap).4 [regressions] Бэкстоп поллинга в
stopping.stoppingдобавлен в фаза-чек idle-cap эффекта + веткаPOLL_IDLE_CAPstopping → idle+disarmPoll(неstalled— Stop уже нажат). Конфликта с honor-in-stopping нет (оба → idle, поздний второй идемпотентен/падает в stay). Тесты FSM + компонент (observer-стоп → cap → onResumeFallback(false)). Mutation-verify: снять ветку → poll висит вечно → красный.5 [documentation] Счёт флагов §2 «Net». Теперь «8 → FSM phase/ctx/epoch/event (#1-6,#11,#13), 3 deleted (#8/#9/#10), reconnectTimerRef — effect-owned controller, mountedRef retained» (8+3+1+1=13); отмечено, что attachAbortRef (#16) вне набора #1-13.
DROP-блок не трогал. Гейт:
src/features/ai-chat35 файлов / 388 passed (+10 тестов);tsc -p apps/client0 ai-chat-ошибок; редьюсер тотален. Все прежние инварианты целы (disconnect-first, epoch, render-gate, honor-in-stopping, supersede CAS). Внутренний цикл: 1 проход ре-ревью — подтвердил, что фаза-гварды не рубят легитимный reconnect/mount-attach и ownership-ресеты только терминальные (I2 не задет). Браузерный QA заново не гонял — правки чисто редьюсер/тест-слой, reconnect-UI не менялся.Ре-ревью #509 (фикс-раунд
080dd820) — вердикт: APPROVE ✅Весь Do-лист закрыт и проверен адверсариально. Дельта чистая (линейно поверх отревьюенного
803bbd40, +236/−14, только редьюсер + тест-слой + спека). Каждую правку сверил с кодом на предмет НЕ-ломания легитимных путей, и каждый новый тест подтвердил мутацией.1 [stability] Гварды позитивных attach-исходов по фазе —
run-fsm.ts.ATTACH_LIVE/ATTACH_NONE→stay(m)приphase!=="attaching";RECONNECT_ATTACHED— приphase!=="reconnecting". Гварды совпадают с легитимной исходной фазой каждого (mount-attach живёт вattaching, reconnect — вreconnecting), так что легитимный reconnect/mount-attach не рубится. Гонка «POLL_TERMINAL→idle (epoch не тронут, GET не оборван) → поздний live-2xx того же поколения → фантомный streaming» закрыта. Мутация: снял гвардRECONNECT_ATTACHED→ тест «late RECONNECT_ATTACHED after POLL_TERMINAL stays idle» краснеет.2 [regressions] Сброс
ownership:"local"на терминалах —run-fsm.ts. Добавлено в FINISH_CLEAN/ABORT/ERROR, POLL_TERMINAL, RUN_FACT{null}→idle, honor-in-stopping-bypass, FINISH_DISCONNECT→idle(no-run). Середина восстановления (observer/reconnecting/polling) НЕ трогается — observer сохраняется. I2 цел: рантайм захватываетwasObserverизmachineRefв началеonFinishДО dispatch, флаш очереди решается по нему (проверил — осевший observer по-прежнему не флашит). Мутация: убрал ресет из FINISH_CLEAN → «Send now» остаётся скрыт → тест краснеет.3 [test-coverage] Успешный рантайм-исход CAS-supersede — новый интеграционный тест реально гонит
superseding→ A-onFinish→B → 200-POST →SUPERSEDE_READY→streaming, и доказывает выход изsuperseding(повторный «Send now» суперсидит снова,stopзовётся дважды). Мутация (тот самый gap): no-opSUPERSEDE_READY→ тест реально исполняется и падает наexpected stop called 2 times, got 1. Сиблинги 409-MISMATCH→verify и plain-409→баннер тоже добавлены.4 [regressions] Бэкстоп поллинга в
stopping—stoppingдобавлен в фаза-чек idle-cap эффекта + веткаPOLL_IDLE_CAP: stopping→idle+disarmPoll(неstalled— Stop уже нажат). Конфликта с honor-in-stopping нет (оба →idle, поздний второй идемпотентен). Мутация: снял ветку → poll висит → тест краснеет. Нормальный стоп не капается преждевременно (cap = после DEGRADED_POLL_IDLE_MAX_MS бездействия, таймер чистится при смене фазы).5 [documentation] Счёт §2 «Net» — теперь «8 → FSM (#1-6,#11,#13), 3 deleted (#8/#9/#10), reconnectTimerRef(#7) effect-owned, mountedRef(#12) retained (8+3+1+1=13); attachAbortRef(#16) вне #1-13». Совпадает с таблицей.
✅ Объективка (чистый прогон + мутации)
tsc --noEmit -p apps/client— 0.vitest run src/features/ai-chat— 388 passed (+10 к прошлому раунду).Прежние инварианты целы (disconnect-first routing, epoch-стамп, render-gate, honor-in-stopping, supersede no-overlap). Браузерный QA не гонялся — правки чисто редьюсер/тест-слой, reconnect-UI не менялся; согласен. Стоит стопкой на #487 (PR #504, approved); мерж-порядок 486→487→488. Под мерж ок.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.