fix(ai-chat): «send now» во время detached-run — серверный stop + ограниченный ретрай 409 (#396) #456

Merged
vvzvlad merged 1 commits from fix/396-sendnow-detached-run into develop 2026-07-10 16:03:20 +03:00
Collaborator

Summary

В автономном режиме «Interrupt and send now» во время живого detached-run показывал красный баннер 409 A_RUN_ALREADY_ACTIVE, новый turn не стартовал. sendNow делал только локальный stop() (abort SSE), который сервер по дизайну #184/#234 игнорирует (detached-run живёт), а последующий onFinish→flush POST упирался в гейт «один активный run на чат». handleStop делает правильно (доп. onServerStop), sendNow — нет.

closes #396

Вариант A (клиентский, горячий путь сервера не тронут):

  • sendNow в автономном режиме дополнительно зовёт onServerStop(chatId) (или откладывает через stopPendingRef, если chatId ещё не усыновлён — как handleStop) и взводит one-shot supersedeRetryRef ДО stop();
  • транспорт-fetch на supersede-отправке (и только на ней) ретраит РОВНО 409 с body.code===A_RUN_ALREADY_ACTIVE до 4 попыток с бэкоффом 150/300/600ms; onServerStop гарантирует осадку run → ретрай сходится. Обычная отправка падает на 409 мгновенно (регресс-гард two-tab). isRunAlreadyActive читает response.clone() → оригинальное тело возвращается потребителю ai-sdk нетронутым.
  • 409 всегда до записи user-строки (pre-check/beginRun раньше insert) → повтор POST безопасен, дублей нет.

How verified

Стенд (vitest):

  • tsc --noEmit -p apps/client → без новых ошибок (единственная — предсуществующая image-menu.tsx).
  • vitest run chat-thread.test.tsx42 passed: autonomous sendNow зовёт onServerStop; legacy — нет; supersede ретраит 409 и проходит на 2-й; one-shot; bounded(4); игнор non-409 и 409-с-другим-body; legacy-no-op; strand-path (взведён, но POST не ушёл → следующая обычная отправка НЕ ретраит).

Внутренний цикл: 2 прохода. Ревью нашло CRITICAL: supersedeRetryRef застревал взведённым, когда sendNow взвёл, но POST не ушёл (promoted head удалён → flushNext() false; либо wasResumed) — следующая обычная отправка молча ретраила настоящий 409. Починка: разоружать флаг симметрично остальным one-shot (в ветке !flushNext() и в isStreaming-defuse-эффекте; транспорт read-and-clear на входе). Мутационно: убрать disarm → strand-тест краснеет (4 вызова вместо 1). Комментарий про «can never leak» приведён к реальному инварианту.

Форк ревьюеру (не блокер, сознательно не делал): backoff-setTimeout не реагирует на abort/unmount во время сна (до 600ms лишней задержки, без утечки/throw) — Вариант B из тикета (серверный атомарный stop-and-supersede) остаётся осознанным рефактором.

Checklist

  • критерии приёмки из #396 выполнены
  • серверный горячий путь не тронут; легаси-режим не изменён
## Summary В автономном режиме «Interrupt and send now» во время живого detached-run показывал красный баннер `409 A_RUN_ALREADY_ACTIVE`, новый turn не стартовал. `sendNow` делал только локальный `stop()` (abort SSE), который сервер по дизайну #184/#234 игнорирует (detached-run живёт), а последующий `onFinish→flush` POST упирался в гейт «один активный run на чат». `handleStop` делает правильно (доп. `onServerStop`), `sendNow` — нет. closes #396 Вариант A (клиентский, горячий путь сервера не тронут): - `sendNow` в автономном режиме дополнительно зовёт `onServerStop(chatId)` (или откладывает через `stopPendingRef`, если chatId ещё не усыновлён — как `handleStop`) и взводит one-shot `supersedeRetryRef` ДО `stop()`; - транспорт-`fetch` на supersede-отправке (и только на ней) ретраит РОВНО `409` с `body.code===A_RUN_ALREADY_ACTIVE` до 4 попыток с бэкоффом 150/300/600ms; `onServerStop` гарантирует осадку run → ретрай сходится. Обычная отправка падает на 409 мгновенно (регресс-гард two-tab). `isRunAlreadyActive` читает `response.clone()` → оригинальное тело возвращается потребителю ai-sdk нетронутым. - 409 всегда до записи user-строки (pre-check/beginRun раньше `insert`) → повтор POST безопасен, дублей нет. ## How verified Стенд (vitest): - `tsc --noEmit -p apps/client` → без новых ошибок (единственная — предсуществующая `image-menu.tsx`). - `vitest run chat-thread.test.tsx` → **42 passed**: autonomous sendNow зовёт onServerStop; legacy — нет; supersede ретраит 409 и проходит на 2-й; one-shot; bounded(4); игнор non-409 и 409-с-другим-body; legacy-no-op; **strand-path** (взведён, но POST не ушёл → следующая обычная отправка НЕ ретраит). Внутренний цикл: 2 прохода. Ревью нашло CRITICAL: `supersedeRetryRef` застревал взведённым, когда `sendNow` взвёл, но POST не ушёл (promoted head удалён → `flushNext()` false; либо wasResumed) — следующая обычная отправка молча ретраила настоящий 409. Починка: разоружать флаг симметрично остальным one-shot (в ветке `!flushNext()` и в isStreaming-defuse-эффекте; транспорт read-and-clear на входе). Мутационно: убрать disarm → strand-тест краснеет (4 вызова вместо 1). Комментарий про «can never leak» приведён к реальному инварианту. Форк ревьюеру (не блокер, сознательно не делал): backoff-`setTimeout` не реагирует на abort/unmount во время сна (до 600ms лишней задержки, без утечки/throw) — Вариант B из тикета (серверный атомарный stop-and-supersede) остаётся осознанным рефактором. ## Checklist - [x] критерии приёмки из #396 выполнены - [x] серверный горячий путь не тронут; легаси-режим не изменён
agent_coder added 1 commit 2026-07-10 07:51:05 +03:00
В автономном режиме «Interrupt and send now» во время живого detached-run делал
только локальный stop() (abort SSE), который сервер игнорирует (run живёт по
дизайну #184/#234), поэтому onFinish→flush новый POST упирался в гейт «один
активный run на чат» → 409 A_RUN_ALREADY_ACTIVE, новый turn не стартовал.
handleStop делает правильно (доп. onServerStop), sendNow — нет. Вариант A
(клиентский, горячий путь сервера не тронут):

- sendNow в автономном режиме дополнительно зовёт onServerStop(chatId) (или
  откладывает через stopPendingRef, если chatId ещё не усыновлён — как
  handleStop) и взводит one-shot supersedeRetryRef ДО stop();
- транспорт-fetch на supersede-отправке (и только на ней) ретраит РОВНО 409 с
  body.code===A_RUN_ALREADY_ACTIVE до 4 попыток с бэкоффом 150/300/600ms;
  onServerStop гарантирует осадку run → ретрай сходится. Обычная отправка (не
  взведён флаг) падает на 409 мгновенно. isRunAlreadyActive читает
  response.clone() → тело оригинала возвращается потребителю нетронутым.
  409 всегда до записи user-строки (pre-check/beginRun раньше insert) → повтор
  POST безопасен, дублей нет.

Внутренний цикл: 2 прохода. Ревью нашло CRITICAL: supersedeRetryRef застревал
взведённым, когда sendNow взвёл, но POST не ушёл (promoted head удалён →
flushNext() false; либо wasResumed-return) — следующая обычная отправка молча
ретраила настоящий 409. Починка: разоружать флаг симметрично остальным one-shot
(в ветке !flushNext() и в isStreaming-defuse-эффекте); транспорт read-and-clear
на входе каждой отправки. Мутационно: убрать disarm → strand-тест краснеет
(4 вызова вместо 1). Легаси-режим не тронут (регресс-гард).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-10 07:51:06 +03:00
Collaborator

Ревью — #456 fix(ai-chat): «send now» во время detached-run — авторитетный серверный stop + ограниченный ретрай 409 (#396)

Вердикт: PASS

Полный веер из 9 аспектов (merge-base f8d37d89; +369). Гейт зелёный: client tsc 0, chat-thread 42/42, полный client-сьют exit 0 (порог покрытия).

Корректность race/retry — проверено + мутационно (клик)
  • Ретрай ОГРАНИЧЕН + сходится: ровно 4 попытки (immediate + 150/300/600мс), выход при attempt >= SUPERSEDE_RETRY_DELAYS_MS.length; последний 409 всплывает баннером. Мутация: убрать бонд → 34 теста падают («supersede retry is bounded»).
  • 409-дискриминация: ретраится ТОЛЬКО A_RUN_ALREADY_ACTIVE (через isRunAlreadyActive() на клоне ответа); парс-ошибка/иной 409/500 → false → НЕ ретраить (fail-safe). Мутация: убрать дискриминацию → тест падает («does NOT retry a 409 with a different body»).
  • Порядок гонки: серверный stop до re-POST; бэкофф покрывает окно; потери сообщения нет (POST либо успех, либо 409). Латч supersedeRetryRef армится в sendNow, разоружается на всех 4 путях (read-clear / no-send / isStreaming / unmount) — стейт чист.
  • Взаимодействие: обычный Stop не тронут; non-autonomous режим arm пропускает; обычный send-путь не задет. Security: clone() сохраняет оригинал, ReadableStream-буфер гардирован, парс-ошибки→false, секретов нет. Architecture/conventions/documentation/simplification — LGTM (+132 минимальны, инварианты задокументированы).
DROP — кодеру НЕ делать (лог калибровки)
  • [below-threshold] low [stability] бэкофф-setTimeout ретрая не хранится в ref и не чистится в unmount-cleanup (в отличие от reconnectTimerRef) — при unmount во время бэкоффа (≤1050мс) ин-флайт ретрай-луп доигрывает. Практически БЕЗВРЕДНО: refs разоружены (стейт чист), сообщение всё равно уходит (юзер и просил «send now»), а setState на размонтированном в React 18 — no-op. Гигиенически можно хранить таймер в ref и чистить в cleanup, но не дефект.

DO/ESCALATE нет. Логика ретрая (бонд + 409-дискриминация — load-bearing safety) корректна и мутационно-держится; гонка/латч/взаимодействие верны; покрытие полное (8 новых тестов). Approve.

9 аспектов. Бонд ретрая и 409-дискриминация мутационно-проверены (subagent: 34 / 1 тест падают); латч/порядок/security подтверждены. Leaked-timer — below-threshold DROP (React 18 no-op, send доходит). Escalate нет.

<!-- state:review reviewed_head=3cba5518 round=1 verdict=approved --> ## Ревью — #456 `fix(ai-chat): «send now» во время detached-run — авторитетный серверный stop + ограниченный ретрай 409 (#396)` **Вердикт: PASS** ✅ Полный веер из 9 аспектов (merge-base `f8d37d89`; +369). Гейт зелёный: client tsc 0, `chat-thread` **42/42**, полный client-сьют **exit 0** (порог покрытия). <details> <summary>Корректность race/retry — проверено + мутационно (клик)</summary> - **Ретрай ОГРАНИЧЕН + сходится**: ровно 4 попытки (immediate + 150/300/600мс), выход при `attempt >= SUPERSEDE_RETRY_DELAYS_MS.length`; последний 409 всплывает баннером. **Мутация**: убрать бонд → **34 теста падают** («supersede retry is bounded»). - **409-дискриминация**: ретраится ТОЛЬКО `A_RUN_ALREADY_ACTIVE` (через `isRunAlreadyActive()` на клоне ответа); парс-ошибка/иной 409/500 → false → НЕ ретраить (fail-safe). **Мутация**: убрать дискриминацию → **тест падает** («does NOT retry a 409 with a different body»). - **Порядок гонки**: серверный stop до re-POST; бэкофф покрывает окно; потери сообщения нет (POST либо успех, либо 409). Латч `supersedeRetryRef` армится в sendNow, разоружается на всех 4 путях (read-clear / no-send / isStreaming / unmount) — стейт чист. - **Взаимодействие**: обычный Stop не тронут; non-autonomous режим arm пропускает; обычный send-путь не задет. Security: `clone()` сохраняет оригинал, ReadableStream-буфер гардирован, парс-ошибки→false, секретов нет. Architecture/conventions/documentation/simplification — LGTM (+132 минимальны, инварианты задокументированы). </details> <details> <summary>⛔ DROP — кодеру НЕ делать (лог калибровки)</summary> - `[below-threshold]` `low` **[stability]** бэкофф-`setTimeout` ретрая не хранится в ref и не чистится в unmount-cleanup (в отличие от `reconnectTimerRef`) — при unmount во время бэкоффа (≤1050мс) ин-флайт ретрай-луп доигрывает. Практически БЕЗВРЕДНО: refs разоружены (стейт чист), сообщение всё равно уходит (юзер и просил «send now»), а setState на размонтированном в React 18 — no-op. Гигиенически можно хранить таймер в ref и чистить в cleanup, но не дефект. </details> DO/ESCALATE нет. Логика ретрая (бонд + 409-дискриминация — load-bearing safety) корректна и мутационно-держится; гонка/латч/взаимодействие верны; покрытие полное (8 новых тестов). Approve. <sub>9 аспектов. Бонд ретрая и 409-дискриминация мутационно-проверены (subagent: 34 / 1 тест падают); латч/порядок/security подтверждены. Leaked-timer — below-threshold DROP (React 18 no-op, send доходит). Escalate нет.</sub>
agent_reviewer added review/approved and removed review/needs labels 2026-07-10 08:15:38 +03:00
vvzvlad merged commit 23966ce51c into develop 2026-07-10 16:03:20 +03:00
Sign in to join this conversation.