fix(ai-chat): «Send now» (немедленная отправка) ломается при активном detached-run — A_RUN_ALREADY_ACTIVE #396
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?
Симптом
При нажатии «Interrupt and send now» (немедленная отправка сообщения из очереди во время стрима) в автономном режиме (
settings.ai.autonomousRuns=true) всплывает красный баннер:То есть попытка немедленно отправить сообщение (запустить новый стрим), пока предыдущий стрим ещё идёт, отбивается сервером.
Репро
settings.ai.autonomousRuns.A_RUN_ALREADY_ACTIVE, новый turn не стартует.Корневая причина
Механику «send now» писали под легаси-режим (turn привязан к сокету) и не адаптировали под серверный гейт «один активный run на чат» из #184.
sendNowво время стрима делает только локальныйstop():apps/client/src/features/ai-chat/components/chat-thread.tsx(sendNow, ~681–706)Дальше
onFinish({ isAbort: true })в веткеflushOnAbortRefнемедленно ре-отправляет промоутнутое сообщение (flushNext → sendMessage), т.е. запускает новыйPOST /api/ai-chat/stream.Но по дизайну #184 detached-run живёт независимо от сокета: локальный
stop()для сервера — это обычный дисконнект, который run игнорирует (controller.ts,onClose: «run continues server-side»). Поэтому старый run остаётся активным, а новый POST упирается в гейт:getActiveForChatвapps/server/src/core/ai-chat/ai-chat.controller.ts(~440–451), либоbeginRun(партиал-уникальный индекс) вapps/server/src/core/ai-chat/ai-chat.service.ts(~688–715)→
409 A_RUN_ALREADY_ACTIVE.Для сравнения:
handleStopв автономном режиме делает всё правильно — дополнительно вызываетonServerStop?.(chatIdRef.current). ВsendNowэтого вызова нет — это и есть пропуск.Почему в легаси-режиме бага нет: там run-строк и гейта нет, turn привязан к сокету, локальный
stop()реально обрывает серверный turn.Важно для фикса: 409 всегда возникает до записи пользовательского сообщения в БД (и pre-check, и
beginRun-гейт срабатывают раньшеaiChatMessageRepo.insert), поэтому повторный POST того же запроса безопасен — дублей строк не будет.Предлагаемое решение
Смысл «send now» в автономном режиме = «останови текущий run и запусти новый». Нужно (а) авторитетно остановить серверный run и (б) начать новый turn только после освобождения слота.
Вариант A (рекомендуемый, клиентский, не трогает горячий путь сервера)
A1. В стрим-ветке
sendNowдополнительно вызывать авторитетную серверную остановку (как вhandleStop):A2. В send-ветке кастомного
fetchтранспорта (chat-thread.tsx~361–388) добавить ограниченный ретрай на409 A_RUN_ALREADY_ACTIVE, включаемый только для supersede-отправки (черезsupersedeRetryRef, one-shot), с бэкоффом (~150/300/600/1000ms). СерверныйonServerStopгарантирует, что run осядет, поэтому ретрай надёжно сходится. Обычный конфликт двух вкладок при этом по-прежнему падает мгновенно.Плюсы: локально, без изменений горячего серверного пути, малый риск.
Минусы: ретрай/бэкофф — тайминговая эвристика (но сходящаяся).
Вариант B (альтернатива, серверный атомарный stop-and-supersede)
Стрим-эндпоинт при явном сигнале supersede, если на чате есть активный run, останавливает его и ждёт осадки (ограниченными поллами
getActiveForChat), затем делаетbeginRun— вместо 409. Клиенту тогда не нужны ни отдельныйonServerStop, ни ретрай.Плюсы: правильная семантика, без клиентских костылей, устойчивее к phase-2 (мульти-инстанс).
Минусы: трогает горячий путь стрима, добавляет wait-loop, выше риск.
Рекомендация: начать с варианта A, вариант B держать как осознанный рефактор.
Пограничные случаи
chatIdRef.current === null):onServerStopвызвать нельзя, ретраи не сойдутся (старый run не остановлен). Тот же под-кейс, что задокументирован дляhandleStop(#234 F5). Решение: переиспользоватьstopPendingRef(отложить остановку доonServerChatId-усыновления) либо явно задокументировать как известное сужение.onServerStopупал: ретраи исчерпаются → 409-баннер. Приемлемо, но проверить, что промоутнутое сообщение не теряется (сейчасflushNextудаляет его из очереди до отправки — остаётся как оптимистичный user-message в транскрипте).DefaultChatTransportшлёт body строкой JSON (а не одноразовымReadableStream), иначе повторныйfetch(input, init)не сработает.sendNowне должен ни зватьonServerStop, ни включать ретрай — оставить поведение прежним (регресс-гард).supersedeRetryRefне протекает на обычные отправки (one-shot read-and-clear).Тесты
sendNowво время стрима в автономном режиме вызываетonServerStopс id чата (сейчас вchat-thread.test.tsxпроверяется только флагinterrupted).409 A_RUN_ALREADY_ACTIVEи проходит, когда слот освобождается; обычная отправка не ретраится; ретрай ограничен (исчерпание → ошибка всплывает) и не реагирует на другие коды/статусы.sendNowне зовётonServerStopи не ретраит.Затронутые файлы
apps/client/src/features/ai-chat/components/chat-thread.tsx—sendNow(~681–706), send-веткаfetchтранспорта (~361–388),onFinishflush-on-abort (~505–517), ориентирhandleStop(~717–743)apps/server/src/core/ai-chat/ai-chat.controller.ts(~440–451),apps/server/src/core/ai-chat/ai-chat.service.ts(~688–715)