fix(ai-chat): «send now» во время detached-run — серверный stop + ограниченный ретрай 409 (#396) #456
Reference in New Issue
Block a user
Delete Branch "fix/396-sendnow-detached-run"
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
В автономном режиме «Interrupt and send now» во время живого detached-run показывал красный баннер
409 A_RUN_ALREADY_ACTIVE, новый turn не стартовал.sendNowделал только локальныйstop()(abort SSE), который сервер по дизайну #184/#234 игнорирует (detached-run живёт), а последующийonFinish→flushPOST упирался в гейт «один активный run на чат».handleStopделает правильно (доп.onServerStop),sendNow— нет.closes #396
Вариант A (клиентский, горячий путь сервера не тронут):
sendNowв автономном режиме дополнительно зовётonServerStop(chatId)(или откладывает черезstopPendingRef, если chatId ещё не усыновлён — какhandleStop) и взводит one-shotsupersedeRetryRefДОstop();fetchна supersede-отправке (и только на ней) ретраит РОВНО409сbody.code===A_RUN_ALREADY_ACTIVEдо 4 попыток с бэкоффом 150/300/600ms;onServerStopгарантирует осадку run → ретрай сходится. Обычная отправка падает на 409 мгновенно (регресс-гард two-tab).isRunAlreadyActiveчитаетresponse.clone()→ оригинальное тело возвращается потребителю ai-sdk нетронутым.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
Ревью — #456
fix(ai-chat): «send now» во время detached-run — авторитетный серверный stop + ограниченный ретрай 409 (#396)Вердикт: PASS ✅
Полный веер из 9 аспектов (merge-base
f8d37d89; +369). Гейт зелёный: client tsc 0,chat-thread42/42, полный client-сьют exit 0 (порог покрытия).Корректность race/retry — проверено + мутационно (клик)
attempt >= SUPERSEDE_RETRY_DELAYS_MS.length; последний 409 всплывает баннером. Мутация: убрать бонд → 34 теста падают («supersede retry is bounded»).A_RUN_ALREADY_ACTIVE(черезisRunAlreadyActive()на клоне ответа); парс-ошибка/иной 409/500 → false → НЕ ретраить (fail-safe). Мутация: убрать дискриминацию → тест падает («does NOT retry a 409 with a different body»).supersedeRetryRefармится в sendNow, разоружается на всех 4 путях (read-clear / no-send / isStreaming / unmount) — стейт чист.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 нет.