ADR 0124 — Группировка состояния канала хода, извлечённая в useTurnoDoAgente
- Статус: принят
- Дата: 2026-08-30
- Ссылки (без правки): ADR 0122
(этот ADR — тот «собственный ADR, позже, с отдельным номером», который раздел
Последствий 0122 назвал и отложил); PR механической дедупликации, на который
опирается это извлечение (
refactor(web): termina a deduplicação do arme de turno no SessionPage.tsx, слит до этого ADR, без собственного ADR — упомянут здесь как предпосылка, по тому же образцу, по которому 0122 ссылался на ADR 0121, не редактируя его).
Контекст
ADR 0122 намеренно оставил группировку состояния канала хода вне своей
механической декомпозиции SessionPage.tsx на пять PR, назвав её
«переплетённым потоком управления, [...] проектным решением о том, где должно
жить состояние жизненного цикла хода, а не механическим перемещением» и назвав
turnoViaCanal, statusAgent, pensandoVisivel, atividadeDoTurno
«читаемыми И записываемыми четырьмя разными обработчиками входа хода
(handleSend, handleReadiness,
handleArchitectureReadiness, handleAcceptHandoff)». Это отложенная работа.
Перечитывание файла построчно для очерчивания рамок показало, что собственный
подсчёт ADR 0122 оказался ниже реального: он называет четыре обработчика входа
хода, но реальных точек входа записи в этой группировке семь. Три
названных плюс handleAcceptHandoff — это четыре; остальные три — это сам
эффект канала (обработчики onAgentDelta/onToolCall/onAgentDone/
onAgentStatus/onEvent из connectSessionHeartbeat — один useEffect, но
поверхность записи не менее реальная, чем любой обработчик), callback
StructuredQuestionCard (onTurnoIniciado/onTurnoTerminado, связанный с
AnswerStructuredQuestionUseCase) и handleReturnStory (RN-174,
ReturnStoryUseCase → reviseStory в po_server) — который ADR 0122 ни разу
не упомянул, хотя он уже тогда корректно вызывал
iniciarTurnoDoAgente/finalizarTurnoDoAgente.
PR механической дедупликации, предшествующий этому (уже слит), закрыл второй
пробел, найденный в том же проходе: iniciarTurnoDoAgente/
finalizarTurnoDoAgente уже существовали как полная пара useCallback, но их
вызывали только две из семи точек входа — handleSend, handleReadiness и
handleArchitectureReadiness каждый дублировали одно и то же пятистрочное
взведение inline вместо вызова существующей функции. Тот PR заменил три
дубликата вызовами iniciarTurnoDoAgente (которая получила там параметр
{ comStatus } — см. Решение ниже) и ввёл cancelarTurnoOtimista. PR этого
ADR — вторая половина: перенести уже дедуплицированное состояние, эффект
канала и три функции жизненного цикла из SessionPage.tsx в хук.
Решение
Новый модуль apps/web/src/lib/session-turno.ts, экспортирующий хук:
function useTurnoDoAgente(
projectId: string,
sessionId: string,
sessionStatus: string | undefined,
queryClient: QueryClient,
) {
return {
// reads
streaming, streamingText, streamingAgent, turnoViaCanal, statusAgent,
pensandoVisivel, atividadeDoTurno, optimisticUser,
// imperative API
iniciarTurnoDoAgente, finalizarTurnoDoAgente, cancelarTurnoOtimista,
// raw setters/ref — two call sites, and only two
setStreaming, setStreamingText, setOptimisticUser,
setTurnoViaCanal, turnoAgentRef,
};
}
Объект, а не кортеж — 13 полей; позиционная деструктуризация была бы
нечитаемой и хрупкой к переупорядочиванию. SessionPage.tsx вызывает его
один раз, сразу после запроса session, от которого он зависит ради
sessionStatus, и деструктурирует каждое поле под тем же именем, что уже
было, — каждая точка чтения в JSX, каждое тело обработчика и затвор
refetchInterval у useSessionEvents (который читает streaming) продолжают
работать без изменений.
До этого в коде не было прецедента хука, который одновременно владеет
состоянием и императивным API: useAutoCollapseSidebar
(lib/sidebar-state.ts) возвращает void — это только зарегистрированный
эффект. useSessionReadiness (lib/session-readiness.ts, ADR 0122 PR 5) —
чистая функция двух параметров, без собственных useState/эффекта. Ни один из
них не является образцом, которому следует этот хук; это сказано здесь явно,
а не намёком на «существующий образец».
Почему эффект канала переносится целиком. useEffect, вызывающий
connectSessionHeartbeat, — седьмая точка входа записи, названная выше, и он
построчно проверен как на 100% механизм жизненного цикла хода — даже
опережающий refetch в onEvent читает streamingRef (часть этой же
группировки), чтобы решить, запускаться ли. Ничто в эффекте не выходит за
пределы группировки, поэтому он переносится с полным массивом зависимостей,
без изменений.
Почему cancelarTurnoOtimista покрывает только две из пяти форм, которыми
этот файл отменяет взведение. Существует пять разных блоков
сброса-при-отказе, разбросанных по обработчикам, в трёх разных формах, а не
одна форма, повторённая пять раз:
- Предпроверка
handleSend(startAgent('criativo')падает ещё до того, как агент вообще известен): 2 поля (streaming,optimisticUser) — остальное так и не взводилось. Остаётся двумя сырыми вызовамиsetStateвSessionPage.tsx, использующимиsetStreaming/setOptimisticUser, которые хук выставляет ровно для этого. - Ветка агента в
handleSend: 4 поля полного взведения плюсoptimisticUser, которого само взведение никогда не касается (он задаётся безусловно в началеhandleSend, ещё до того, как агент разрешён). СтановитсяcancelarTurnoOtimista(); setOptimisticUser(null);— два вызова, а не одна большая функция, потому что жизненный циклoptimisticUserпринадлежитhandleSend, а не паре взвести/снять. handleReadiness: 4 поля (streaming,turnoAgentRef,statusAgent,turnoViaCanal) — ровно то, что покрываетcancelarTurnoOtimista.handleArchitectureReadiness: блок, ИДЕНТИЧНЫЙ блокуhandleReadiness, — то же покрытие.handleAcceptHandoff: 2 поля (turnoAgentRef,turnoViaCanal) — он вообще никогда не взводитstreaming/statusAgent, потому что его kickoff — это асинхронныйGenServer.castв engine (находка B): обработчик в момент клика ещё не знает, что начнётся реальный ход, знает только, кто будет отвечать. Вызов здесьcancelarTurnoOtimistaмолча связал бы обработчик, который никогда не трогалstreaming/statusAgent, с функцией, чьё имя обещает отменить оба, — та же ловушка, на которую ADR 0122 уже указал для группировки в целом. Этот блок остаётся inline вhandleAcceptHandoff, ровно как всегда был, используяsetTurnoViaCanal/turnoAgentRef, которые хук выставляет для этой единственной точки вызова.
cancelarTurnoOtimista сделана, чтобы покрывать ровно №3 и №4 — два
идентичных блока — и ничего больше. Шестая функция, покрывающая также №5,
рассматривалась и была отвергнута: она существовала бы ради одного
вызывающего, дублируя то, что две сырые операции уже говорят напрямую.
Почему два PR, а не один. Механическая дедупликация (три дублированных
взведения заменены вызовами уже существовавшей iniciarTurnoDoAgente, два
идентичных блока отказа заменены новой cancelarTurnoOtimista) и извлечение
модуля (состояние, эффект канала и три функции перемещены в
lib/session-turno.ts) — это разные виды риска. Первый — «это, вызов за
вызовом, то же самое, что пять строк, которые он заменяет», ревьюится по diff
за минуты. Второй — «замыкания на queryClient/projectId/sessionId и
массивы зависимостей эффектов переживают смену области модуля», и это нужно
было проверить отдельно, не конкурируя за внимание с «эта версия взведения
тонко отличается от той». Первый PR был слит до начала второго.
Последствия
SessionPage.tsxуходит с 2 681 до 2 479 строк (wc -l) — объявления состояния, документированные JSDociniciarTurnoDoAgente/finalizarTurnoDoAgente/cancelarTurnoOtimista, эффект канала и эффект взведения/снятияpensandoVisivelуходят вlib/session-turno.ts(341 строка, включая комментарии — та же плотность, что у заменяемого, потому что вместе с кодом переместилось рассуждение, обосновывавшее каждое поле и каждую ветку, а не только код).- Это закрывает пункт группировки канала хода, который раздел «Явно вне
рамок» ADR 0122 отложил, именно его.
ProjectSettingsTab.tsx— другой файл, названный в той же строке техдолга, что иSessionPage.tsx, — этим ADR не затронут и остаётся полностью отдельным решением о рамках, по-прежнему открытым, без даты; ничто здесь не меняет его состояния. - Девять тестов, названных покрытием регрессии этой группировки
(
SessionPage.pista-e-status,.cancelar-turno,.turno-preso,.readiness-turno-preso,.handoff-devlead-e-colapso,.ideacao-automatica,.arquiteto-modelo-icone,.agente-mais-recente,.promocao-inline-e-volta— последний единственный файл, покрывающийhandleReturnStory/RN-174, который ADR 0122 ни разу не назвал), все проходят без правки, вместе со всем наборомSessionPage.*.test.tsx, доказывая, что изменение не повлияло ни на какое наблюдаемое поведение. - Находка о пяти формах выше (Решение, «почему
cancelarTurnoOtimistaпокрывает только две из пяти») теперь записана один раз, здесь, а не живёт только в комментариях кода — будущее изменение любого из пяти блоков сброса имеет этот ADR как ссылку на то, почему сегодня это не одна и та же функция.
Отвергнутые альтернативы
- Шестая функция, покрывающая сброс 2 полей в
handleAcceptHandoff(блок №5 выше), чтобы каждый обработчик вызывал именованную функцию вместо двух сырых операций. Отвергнуто: у неё был бы ровно один вызывающий, а две строки, которые она обернула бы (turnoAgentRef.current = null; setTurnoViaCanal(false);), уже говорят, что делают, без промежуточного имени, которое нужно искать. - Расширить
cancelarTurnoOtimista, чтобы она также сбрасывалаatividadeDoTurno, по теории, что неудавшийся ход всегда должен очищать полосу активности. Отвергнуто: ни один из трёх путей отказа, зависящих отawait(handleReadiness,handleArchitectureReadiness, ветка агента вhandleSend), сегодня не сбрасываетatividadeDoTurno, хотя вызов может длиться до 120 с, и deltas/tool-calls канала могли уже за это время наполнить reducer. Это пробел, принятый в коде таким, каким он был до этого ADR, а не то, что это извлечение было призвано исправить, — добавление новогоdispatchAtividade({ tipo: 'reset' })здесь было бы молчаливым изменением поведения, едущим попутно в изменении, у которого его не должно быть. - Один PR вместо двух. Отвергнуто по причине, изложенной выше в Решении: дедупликация и смена модуля — разные виды риска, и их смешивание затруднило бы на ревью изоляцию регрессии в любой из них.