fix(ai-chat): «Send now» (немедленная отправка) ломается при активном detached-run — A_RUN_ALREADY_ACTIVE #396

Open
opened 2026-07-06 23:11:14 +03:00 by agent_vscode · 0 comments
Collaborator

Симптом

При нажатии «Interrupt and send now» (немедленная отправка сообщения из очереди во время стрима) в автономном режиме (settings.ai.autonomousRuns=true) всплывает красный баннер:

{"message":"An agent run is already in progress for this chat","code":"A_RUN_ALREADY_ACTIVE"}

То есть попытка немедленно отправить сообщение (запустить новый стрим), пока предыдущий стрим ещё идёт, отбивается сервером.

Репро

  1. Включить settings.ai.autonomousRuns.
  2. Начать turn у агента (пошёл стрим).
  3. Пока идёт стрим — набрать сообщение (оно уходит в очередь).
  4. Нажать на очередном сообщении «Interrupt and send now» (иконка ▶).
  5. → красный баннер 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)

if (liveStreaming) {
  setQueue(promoteToHead(queuedRef.current, id));
  flushOnAbortRef.current = true;
  interruptNextSendRef.current = true;
  stop(); // <-- только локальный abort SSE, серверная остановка НЕ вызывается
}

Дальше 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 упирается в гейт:

  • pre-check 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):

if (autonomousRunsEnabled && chatIdRef.current) {
  supersedeRetryRef.current = true; // arm bounded retry for the re-send
  onServerStop?.(chatIdRef.current);
}

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 держать как осознанный рефактор.

Пограничные случаи

  1. Send now в самом начале первого turn нового чата, до усыновления chatId (chatIdRef.current === null): onServerStop вызвать нельзя, ретраи не сойдутся (старый run не остановлен). Тот же под-кейс, что задокументирован для handleStop (#234 F5). Решение: переиспользовать stopPendingRef (отложить остановку до onServerChatId-усыновления) либо явно задокументировать как известное сужение.
  2. onServerStop упал: ретраи исчерпаются → 409-баннер. Приемлемо, но проверить, что промоутнутое сообщение не теряется (сейчас flushNext удаляет его из очереди до отправки — остаётся как оптимистичный user-message в транскрипте).
  3. Тело запроса перечитываемо при ре-fetch: убедиться, что DefaultChatTransport шлёт body строкой JSON (а не одноразовым ReadableStream), иначе повторный fetch(input, init) не сработает.
  4. Легаси-режим (autonomousRuns off): sendNow не должен ни звать onServerStop, ни включать ретрай — оставить поведение прежним (регресс-гард).
  5. Resumed turn: кнопка «Send now» уже скрыта на resumed-turn — убедиться, что supersedeRetryRef не протекает на обычные отправки (one-shot read-and-clear).

Тесты

  • sendNow во время стрима в автономном режиме вызывает onServerStop с id чата (сейчас в chat-thread.test.tsx проверяется только флаг interrupted).
  • Транспорт: supersede-отправка ретраится на 409 A_RUN_ALREADY_ACTIVE и проходит, когда слот освобождается; обычная отправка не ретраится; ретрай ограничен (исчерпание → ошибка всплывает) и не реагирует на другие коды/статусы.
  • Регресс: в легаси-режиме sendNow не зовёт onServerStop и не ретраит.

Затронутые файлы

  • apps/client/src/features/ai-chat/components/chat-thread.tsxsendNow (~681–706), send-ветка fetch транспорта (~361–388), onFinish flush-on-abort (~505–517), ориентир handleStop (~717–743)
  • (вариант B) apps/server/src/core/ai-chat/ai-chat.controller.ts (~440–451), apps/server/src/core/ai-chat/ai-chat.service.ts (~688–715)
## Симптом При нажатии «Interrupt and send now» (немедленная отправка сообщения из очереди во время стрима) в автономном режиме (`settings.ai.autonomousRuns=true`) всплывает красный баннер: ```json {"message":"An agent run is already in progress for this chat","code":"A_RUN_ALREADY_ACTIVE"} ``` То есть попытка немедленно отправить сообщение (запустить новый стрим), пока предыдущий стрим ещё идёт, отбивается сервером. ## Репро 1. Включить `settings.ai.autonomousRuns`. 2. Начать turn у агента (пошёл стрим). 3. Пока идёт стрим — набрать сообщение (оно уходит в очередь). 4. Нажать на очередном сообщении «Interrupt and send now» (иконка ▶). 5. → красный баннер `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) ```ts if (liveStreaming) { setQueue(promoteToHead(queuedRef.current, id)); flushOnAbortRef.current = true; interruptNextSendRef.current = true; stop(); // <-- только локальный abort SSE, серверная остановка НЕ вызывается } ``` Дальше `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 упирается в гейт: - pre-check `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`): ```ts if (autonomousRunsEnabled && chatIdRef.current) { supersedeRetryRef.current = true; // arm bounded retry for the re-send onServerStop?.(chatIdRef.current); } ``` **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 держать как осознанный рефактор. ## Пограничные случаи 1. **Send now в самом начале первого turn нового чата, до усыновления chatId** (`chatIdRef.current === null`): `onServerStop` вызвать нельзя, ретраи не сойдутся (старый run не остановлен). Тот же под-кейс, что задокументирован для `handleStop` (#234 F5). Решение: переиспользовать `stopPendingRef` (отложить остановку до `onServerChatId`-усыновления) либо явно задокументировать как известное сужение. 2. **`onServerStop` упал:** ретраи исчерпаются → 409-баннер. Приемлемо, но проверить, что промоутнутое сообщение не теряется (сейчас `flushNext` удаляет его из очереди до отправки — остаётся как оптимистичный user-message в транскрипте). 3. **Тело запроса перечитываемо при ре-fetch:** убедиться, что `DefaultChatTransport` шлёт body строкой JSON (а не одноразовым `ReadableStream`), иначе повторный `fetch(input, init)` не сработает. 4. **Легаси-режим (autonomousRuns off):** `sendNow` не должен ни звать `onServerStop`, ни включать ретрай — оставить поведение прежним (регресс-гард). 5. **Resumed turn:** кнопка «Send now» уже скрыта на resumed-turn — убедиться, что `supersedeRetryRef` не протекает на обычные отправки (one-shot read-and-clear). ## Тесты - `sendNow` во время стрима в автономном режиме вызывает `onServerStop` с id чата (сейчас в `chat-thread.test.tsx` проверяется только флаг `interrupted`). - Транспорт: supersede-отправка ретраится на `409 A_RUN_ALREADY_ACTIVE` и проходит, когда слот освобождается; обычная отправка **не** ретраится; ретрай ограничен (исчерпание → ошибка всплывает) и не реагирует на другие коды/статусы. - Регресс: в легаси-режиме `sendNow` не зовёт `onServerStop` и не ретраит. ## Затронутые файлы - `apps/client/src/features/ai-chat/components/chat-thread.tsx` — `sendNow` (~681–706), send-ветка `fetch` транспорта (~361–388), `onFinish` flush-on-abort (~505–517), ориентир `handleStop` (~717–743) - (вариант B) `apps/server/src/core/ai-chat/ai-chat.controller.ts` (~440–451), `apps/server/src/core/ai-chat/ai-chat.service.ts` (~688–715)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#396