From 22f687c39ef4d0434453e427fb051c50e30be569 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 02:51:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(ai-chat):=20=D0=B0=D0=B2=D1=82=D0=BE-?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D0=BE=D0=BD=D0=BD=D0=B5=D0=BA=D1=82=20=D0=BA?= =?UTF-8?q?=20detached-=D1=80=D0=B0=D0=BD=D1=83=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D1=8B=D0=B2=D0=B0=20SSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Автономный ран продолжается на сервере при обрыве SSE (Safari роняет длинный стрим), но клиент показывал баннер 'Lost connection' и мёртвую вкладку до ручной перезагрузки: resumeStream() звался только на mount. Добавлен недостающий триггер. - chat-thread.tsx: в onFinish на живом isDisconnect (гард !wasResumed && autonomousRunsEnabled && mounted && assistant) -> beginReconnect с экспон. backoff (1/2/4/8/16с, лимит 5). Стоп: status->streaming / 2xx re-attach / терминальный хвост reconcile / stop / unmount. Исчерпание -> Retry. - Дедуп (главный риск): зеркалит mount strip/anchor — пиннит текущий streaming-ряд как anchor (id ассистент-строки), стрипает его из стора ДО replay, сервер ?expect=live&anchor= пересобирает без дублей; на отказе/204 строка восстанавливается через onNoActiveStream (контент не теряется). - 204/overflow -> degraded poll через существующий onNoActiveStream. - RUN_STREAM_MAX_BUFFER_BYTES 4->32МБ (марафонские раны 11-25мин переполняли 4МБ); 204->poll остаётся backstop. Degraded-poll: фиксированный 10-мин-от-старта кап заменён на inactivity-кап (продлевается пока приходят новые ряды). - UI: баннер 'reconnecting… (N/5)' + ручной Retry на исчерпании. closes #430 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/components/ai-chat-window.tsx | 47 +++- .../ai-chat/components/chat-thread.test.tsx | 167 +++++++++++++ .../ai-chat/components/chat-thread.tsx | 236 +++++++++++++++++- .../ai-chat-stream-registry.service.ts | 20 +- .../ai-chat/ai-chat-stream-registry.spec.ts | 20 +- 5 files changed, 462 insertions(+), 28 deletions(-) diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index dc08e756..7fa218e8 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -86,11 +86,19 @@ const MIN_HEIGHT = 400; // Margin kept between the window and the viewport edges while dragging. const EDGE_MARGIN = 8; -// #184 phase 1.5: hard cap on the degraded-poll fallback. The poll is armed when -// a resume attempt could not attach to the live run and disarmed by the thread on -// settle / local stream; this cap is the ONLY backstop against an endless tick -// (a stuck 'streaming' row before the boot-sweep, or a user-tail 204 with no run). -const DEGRADED_POLL_MAX_MS = 10 * 60_000; +// #184 phase 1.5 / #430: backstop for the degraded-poll fallback. The poll is +// armed when a resume attempt could not attach to the live run and disarmed by the +// thread on settle / local stream; this cap is the ONLY backstop against an endless +// tick (a stuck 'streaming' row before the boot-sweep, or a user-tail 204 with no +// run). +// +// #430: measured from RUN ACTIVITY, not from arm-time. A real autonomous run takes +// 11-25 min — longer than a fixed 10-min-from-start cap, which used to cut the poll +// off mid-run. Instead we cap on INACTIVITY: keep polling as long as the run is +// still making progress (its persisted rows keep changing), and only give up after +// this long with NO new activity. A genuinely stuck run produces no row changes, so +// the idle cap still bounds it; a long-but-progressing run polls to completion. +const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000; /** Compact token formatter: 1.2M / 3.4k / 950. */ function formatTokens(n: number): string { @@ -254,9 +262,12 @@ export default function AiChatWindow() { // onResumeFallback(true); the thread disarms it on settle / local stream. The // window only OWNS the timer (armedAtRef stamps when it was armed for the cap). const [degradedPoll, setDegradedPoll] = useState(false); - const armedAtRef = useRef(0); + // #430: timestamp of the LAST run activity while the poll is armed — stamped on + // arm and re-stamped whenever the polled rows change (see the effect below). The + // idle cap is measured from this, so a long-but-progressing run keeps polling. + const lastActivityAtRef = useRef(0); const onResumeFallback = useCallback((active: boolean): void => { - if (active) armedAtRef.current = Date.now(); + if (active) lastActivityAtRef.current = Date.now(); setDegradedPoll(active); }, []); // Reset the degraded poll whenever the open chat changes: it is scoped to the @@ -269,18 +280,28 @@ export default function AiChatWindow() { useAiChatMessagesQuery( activeChatId ?? undefined, // DELIBERATELY DUMB (invariant 8 / task 2.4): poll every 2.5s while armed - // and under the 10-min cap; otherwise off. NO error checks (TanStack v5 - // resets fetchFailureCount each fetch, so consecutive errors are not - // expressible — and the poll must survive a server restart) and NO tail - // checks (the settled/local-stream semantics live in ChatThread, which - // disarms via onResumeFallback(false)). The time cap is the only backstop. + // and while the run is still active (#430: under the INACTIVITY cap, not a + // fixed-from-start cap); otherwise off. NO error checks (TanStack v5 resets + // fetchFailureCount each fetch, so consecutive errors are not expressible — + // and the poll must survive a server restart) and NO tail checks (the + // settled/local-stream semantics live in ChatThread, which disarms via + // onResumeFallback(false)). The idle cap is the only backstop. () => degradedPoll === true && - Date.now() - armedAtRef.current < DEGRADED_POLL_MAX_MS + Date.now() - lastActivityAtRef.current < DEGRADED_POLL_IDLE_MAX_MS ? 2500 : false, ); + // #430: re-stamp the activity clock whenever the polled rows change while the + // poll is armed. TanStack keeps the same `messageRows` reference across refetches + // that return deep-equal data (structural sharing), so a new reference means the + // run genuinely progressed — which extends the inactivity cap above. A stuck run + // yields no reference change, so the cap eventually fires and stops the poll. + useEffect(() => { + if (degradedPoll) lastActivityAtRef.current = Date.now(); + }, [degradedPoll, messageRows]); + // #184 reconnect-and-live-follow. Whether detached agent runs are enabled for // this workspace. When the feature is off no runs are ever created, so the // resume attempt would only ever 204; gating ChatThread's resume on it avoids a diff --git a/apps/client/src/features/ai-chat/components/chat-thread.test.tsx b/apps/client/src/features/ai-chat/components/chat-thread.test.tsx index 20b0a3ec..557b4d41 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.test.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.test.tsx @@ -739,3 +739,170 @@ function renderResumable(initialRows: IAiChatMessageRow[]) { act(() => view.rerender()); return { rerender, onResumeFallback }; } + +// #430: auto-reconnect to a DETACHED run after a LIVE SSE disconnect. The mount +// path only resumes on mount/reload; these cover the missing trigger — a live +// `isDisconnect` on onFinish must (backoff-)re-attach WITHOUT a reload, pin+strip +// the live row to avoid duplicates, fall back to the degraded poll on a 204, and +// exhaust to a manual Retry. +describe("ChatThread — live reconnect after isDisconnect (#430)", () => { + // A LIVE local turn that just dropped: the settled tail existed before, and the + // partial assistant row lives only in `messages` (not persisted as a tail). + const settledTail = () => [ + row("u1", "user", undefined, "hi"), + row("a1", "assistant", "succeeded", "done"), + ]; + // The partial assistant message onFinish hands us for the dropped LIVE turn. + const liveMsg = { + id: "a2", + role: "assistant", + parts: [{ type: "text", text: "partial live answer" }], + }; + + beforeEach(() => { + resetState(); + // status "ready": with a live disconnect the mock is not streaming, so the + // status==="streaming" auto-clear effect stays out of the way. + h.state.status = "ready"; + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + cleanup(); + }); + + // Render a NON-resuming mount (settled tail -> no mount resume) with autonomous + // runs on, then simulate a live disconnect via onFinish. + function renderLiveThenDisconnect() { + const view = renderThread({ + autonomousRunsEnabled: true, + initialRows: settledTail(), + }); + // The settled tail must NOT have triggered a mount resume. + expect(h.state.resumeStream).not.toHaveBeenCalled(); + act(() => { + h.state.onFinish?.({ + message: liveMsg, + isAbort: false, + isDisconnect: true, + isError: false, + }); + }); + return view; + } + + // Fire the pending (scheduled) attempt for `attempt` (backoff = 1s,2s,4s,...). + function advanceToAttempt(attempt: number) { + act(() => { + vi.advanceTimersByTime(1000 * 2 ** (attempt - 1)); + }); + } + + // Simulate the reconnect GET returning 204 (nothing live) so the transport's + // no-active-stream recovery runs. + async function reconnect204() { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ status: 204, ok: false }), + ); + await act(async () => { + await h.state.transport!.fetch!("http://x", { method: "GET" }); + }); + } + + // Simulate the reconnect GET returning a live 2xx stream. + async function reconnect200() { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ status: 200, ok: true }), + ); + await act(async () => { + await h.state.transport!.fetch!("http://x", { method: "GET" }); + }); + } + + it("calls resumeStream POST-mount (a live disconnect triggers a backoff reconnect)", () => { + renderLiveThenDisconnect(); + // The banner shows immediately; the attach itself fires after the first backoff. + expect(screen.getByText(/reconnecting/i)).toBeTruthy(); + expect(h.state.resumeStream).not.toHaveBeenCalled(); + advanceToAttempt(1); + // resumeStream is now called AFTER mount — the bug was it only ever fired once + // on mount. The reconnect URL pins expect=live&anchor to OUR run. + expect(h.state.resumeStream).toHaveBeenCalledTimes(1); + expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe( + "/api/ai-chat/runs/c1/stream?expect=live&anchor=a2", + ); + }); + + it("strips the pinned live row before replay so content is NOT duplicated", () => { + renderLiveThenDisconnect(); + advanceToAttempt(1); + // The attempt strips the anchor row from the store (the live replay rebuilds + // it). Apply the setMessages updater to prove it removes exactly the anchor. + const updater = h.state.setMessages.mock.calls.at(-1)![0] as ( + prev: { id: string }[], + ) => { id: string }[]; + expect(updater([{ id: "u1" }, { id: "a2" }])).toEqual([{ id: "u1" }]); + }); + + it("a live re-attach (2xx) clears the reconnect banner", async () => { + renderLiveThenDisconnect(); + advanceToAttempt(1); + await reconnect200(); + expect(screen.queryByText(/reconnecting/i)).toBeNull(); + }); + + it("a 204 arms the degraded poll and backs off to the next attempt", async () => { + const { onResumeFallback } = renderLiveThenDisconnect(); + advanceToAttempt(1); + expect(h.state.resumeStream).toHaveBeenCalledTimes(1); + await reconnect204(); + // Fallback engaged: the degraded poll is armed (204 -> onNoActiveStream). + expect(onResumeFallback).toHaveBeenCalledWith(true); + // Still reconnecting — the banner advanced to attempt 2/5. + expect(screen.getByText(/reconnecting.*2\/5/i)).toBeTruthy(); + // The next backoff fires attempt 2 (another resumeStream). + advanceToAttempt(2); + expect(h.state.resumeStream).toHaveBeenCalledTimes(2); + }); + + it("exhausts the attempt limit into a manual Retry, which restarts the sequence", async () => { + renderLiveThenDisconnect(); + // Drive all 5 attempts, each failing with a 204. + for (let n = 1; n <= 5; n++) { + advanceToAttempt(n); + expect(h.state.resumeStream).toHaveBeenCalledTimes(n); + await reconnect204(); + } + // The 5th 204 exhausted the cap -> the manual Retry replaces the banner. + expect(screen.queryByText(/reconnecting/i)).toBeNull(); + const retry = screen.getByText("Retry"); + expect(retry).toBeTruthy(); + // Retry fires attempt 1 immediately (no backoff) — a 6th resumeStream. + act(() => { + fireEvent.click(retry); + }); + expect(h.state.resumeStream).toHaveBeenCalledTimes(6); + expect(screen.getByText(/reconnecting/i)).toBeTruthy(); + }); + + it("does NOT reconnect when autonomous runs are disabled", () => { + renderThread({ autonomousRunsEnabled: false, initialRows: settledTail() }); + act(() => { + h.state.onFinish?.({ + message: liveMsg, + isAbort: false, + isDisconnect: true, + isError: false, + }); + }); + expect(screen.queryByText(/reconnecting/i)).toBeNull(); + // The terminal "connection lost" notice is shown instead (unchanged behavior). + expect( + screen.getByText("Connection lost — the answer was interrupted."), + ).toBeTruthy(); + advanceToAttempt(1); + expect(h.state.resumeStream).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index 5ef54585..60b27db8 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -1,7 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { generateId } from "ai"; -import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { + ActionIcon, + Alert, + Box, + Button, + Group, + Loader, + Stack, + Text, + Tooltip, +} from "@mantine/core"; import { IconClockHour4, IconPlayerPlayFilled, @@ -51,6 +61,15 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css"; // from the token rate. const STREAM_THROTTLE_MS = 50; +// #430: auto-reconnect after a LIVE SSE disconnect of a DETACHED (autonomous) run. +// The run keeps executing server-side, so instead of a dead "Lost connection" +// banner we re-attach to the live tail through the SAME resumable machinery the +// mount path uses. Attempts back off exponentially and are capped; on exhaustion +// the user gets a manual Retry (the degraded poll keeps catching up underneath). +const RECONNECT_MAX_ATTEMPTS = 5; +// Backoff before attempt N (1-based): 1s, 2s, 4s, 8s, 16s. +const RECONNECT_BASE_DELAY_MS = 1000; + /** The page the user is currently viewing, sent as chat context. */ export interface OpenPageContext { id: string; @@ -175,6 +194,10 @@ export default function ChatThread({ const reconcileTailRef = useRef(false); const noStreamHandledRef = useRef(false); const onNoActiveStreamRef = useRef<(() => void) | null>(null); + // #430: called from the transport's reconnect-GET success branch when a live + // stream re-attached (2xx, not 204) — clears the reconnect banner. Kept in a ref + // because the transport's fetch closure (useMemo([])) reads it live. + const onReconnectAttachedRef = useRef<(() => void) | null>(null); // Live mount flag. The attach GET and the resumed `onFinish` are async and can // land AFTER this thread unmounts (the parent remounts per chat via `key`); with // chatIdRef then pointing at the NEW chat, an ungated late callback would arm a @@ -378,6 +401,10 @@ export default function ChatThread({ // NOT drop the in-progress row or stop tracking the durable run. if (response.status === 204 || !response.ok) onNoActiveStreamRef.current?.(); + // #430: a 2xx stream re-attached (live tail or finished-replay). Signal + // the reconnect controller to clear its banner. No-op outside an active + // reconnect sequence (e.g. the mount attach), so it is safe here. + else onReconnectAttachedRef.current?.(); return response; } catch (err) { // Network throw: same no-onFinish recovery, then rethrow so the SDK @@ -481,6 +508,31 @@ export default function ChatThread({ ); } } + // (2b) #430: a LIVE (non-resumed) detached run whose SSE just dropped. The + // server run keeps executing, so instead of a dead "Lost connection" banner + // start a reconnect sequence: pin the CURRENT streaming assistant row as the + // strip/anchor (the live tail is the already-shown partial in `messages`, not + // a persistent row) and re-attach to the live tail via the resumable machinery. + const startedReconnect = + isDisconnect && + !wasResumed && + autonomousRunsEnabled === true && + mountedRef.current && + message?.role === "assistant" && + typeof message.id === "string"; + if (startedReconnect) { + beginReconnect({ + id: message.id, + role: "assistant", + content: "", + status: "streaming", + createdAt: new Date().toISOString(), + // Preserve the partial parts so a 204 restore (onNoActiveStream) re-shows + // what was on screen while the degraded poll catches the run up to + // terminal (rowToUiMessage prefers metadata.parts). + metadata: { parts: message.parts }, + }); + } // (3) Standard branches. // Forward the authoritative server chatId (streamed on the assistant // message metadata) so the parent adopts the REAL created chat id for a new @@ -490,9 +542,11 @@ export default function ChatThread({ onTurnFinished(extractServerChatId(message), threadKey); // Show a neutral "stopped" marker for an aborted turn; the red error banner // (via `error`) already covers isError, and a clean finish clears any marker. + // On a live disconnect that STARTED a reconnect, suppress the terminal + // "connection lost" notice — the reconnect banner takes over (#430). if (isError) setStopNotice(null); else if (isAbort) setStopNotice("manual"); - else if (isDisconnect) setStopNotice("disconnect"); + else if (isDisconnect) setStopNotice(startedReconnect ? null : "disconnect"); else setStopNotice(null); // A resumed turn NEVER flushes the queue (invariant 7): skip BOTH the // flush-on-abort branch and the plain flush. The local streamer is the only @@ -579,6 +633,106 @@ export default function ChatThread({ const isStreaming = status === "submitted" || status === "streaming"; + // #430: live-disconnect reconnect controller. `null` = idle; `{ trying, attempt }` + // = a backoff sequence is running (drives the "reconnecting… (N/max)" banner); + // `{ failed }` = attempts exhausted (drives the manual Retry). Mirrored into a ref + // so the transport/onNoActiveStream closures branch on the LIVE value. + type ReconnectState = + | null + | { phase: "trying"; attempt: number } + | { phase: "failed" }; + const [reconnectState, setReconnectState] = useState(null); + const reconnectStateRef = useRef(null); + const setReconnectStatePair = useCallback((s: ReconnectState) => { + reconnectStateRef.current = s; + setReconnectState(s); + }, []); + const reconnectTimerRef = useRef | null>(null); + const clearReconnectTimer = useCallback(() => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }, []); + + // One reconnect attempt — MIRRORS the mount strip/anchor path for the LIVE case. + // beginReconnect pinned strippedRowRef/stripRef to the run's assistant row, so: + // - remove that row from the store (the mount path strips it from the SEED; here + // it is already shown, so filter it out) — the live replay's `text-start` then + // rebuilds it without DUPLICATING parts (the main dedup risk, #430); + // - reset the one-shot 204 guard so onNoActiveStream can fire for THIS attempt; + // - mark the turn resumed (invariant 7/8) so onFinish runs the recovery block and + // never flushes the queue; + // - resumeStream() -> prepareReconnectToStreamRequest builds + // ?expect=live&anchor=, pinning the replay to OUR run (invariant 6). + const attemptReconnectOnce = useCallback( + (attempt: number) => { + if (!mountedRef.current) return; + const anchor = strippedRowRef.current; + if (anchor) { + setMessages((prev) => prev.filter((m) => m.id !== anchor.id)); + } + noStreamHandledRef.current = false; + setResumedTurnPair(true); + setReconnectStatePair({ phase: "trying", attempt }); + void resumeStream(); + }, + [setMessages, setResumedTurnPair, setReconnectStatePair, resumeStream], + ); + + // Schedule attempt `attempt` after an exponential backoff. + const scheduleReconnectAttempt = useCallback( + (attempt: number) => { + clearReconnectTimer(); + setReconnectStatePair({ phase: "trying", attempt }); + reconnectTimerRef.current = setTimeout( + () => attemptReconnectOnce(attempt), + RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1), + ); + }, + [clearReconnectTimer, setReconnectStatePair, attemptReconnectOnce], + ); + + // Start a fresh reconnect sequence, pinning `anchorRow` (the live run's assistant + // row) as the strip/anchor reused by every attempt. + const beginReconnect = useCallback( + (anchorRow: IAiChatMessageRow) => { + if (!autonomousRunsEnabled || !mountedRef.current) return; + strippedRowRef.current = anchorRow; + stripRef.current = true; + scheduleReconnectAttempt(1); + }, + [autonomousRunsEnabled, scheduleReconnectAttempt], + ); + + // Manual Retry (shown once attempts are exhausted): restart at attempt 1 and fire + // immediately (the user asked for it now — no backoff). + const retryReconnect = useCallback(() => { + clearReconnectTimer(); + attemptReconnectOnce(1); + }, [clearReconnectTimer, attemptReconnectOnce]); + + // Live SSE re-attached (the reconnect GET returned a 2xx stream): clear the + // banner + any pending backoff. No-op outside a sequence (e.g. the mount attach). + const onReconnectAttached = useCallback(() => { + if (!mountedRef.current || !reconnectStateRef.current) return; + clearReconnectTimer(); + setReconnectStatePair(null); + }, [clearReconnectTimer, setReconnectStatePair]); + onReconnectAttachedRef.current = onReconnectAttached; + + // The reconnect GET could not attach (204 / error). onNoActiveStream has already + // armed the degraded poll (the robust fallback that drives the row to terminal + // from the DB), so this only decides the LIVE-attach retry: back off and try + // again up to the cap, else surface the manual Retry. + const onReconnectNoStream = useCallback(() => { + const s = reconnectStateRef.current; + if (s?.phase !== "trying") return; + if (s.attempt < RECONNECT_MAX_ATTEMPTS) + scheduleReconnectAttempt(s.attempt + 1); + else setReconnectStatePair({ phase: "failed" }); + }, [scheduleReconnectAttempt, setReconnectStatePair]); + // 204-handler (`onNoActiveStream`): the attach returned 204 — nothing live to // resume (overflow / begin-failure / after retention / anchor-mismatch). One- // shot via noStreamHandledRef (we do NOT null onNoActiveStreamRef). Exactly four @@ -610,7 +764,17 @@ export default function ChatThread({ // (d) 204 means onFinish will NOT fire — clear the suppression flag so it // cannot swallow the NEXT local turn's queue flush. setResumedTurnPair(false); - }, [setMessages, queryClient, onResumeFallback, setResumedTurnPair]); + // (e) #430: if this 204/error landed during a live-disconnect reconnect + // sequence, back off and retry the live attach (or give up to the manual + // Retry). The degraded poll armed in (c) is the fallback either way. + onReconnectNoStream(); + }, [ + setMessages, + queryClient, + onResumeFallback, + setResumedTurnPair, + onReconnectNoStream, + ]); onNoActiveStreamRef.current = onNoActiveStream; // Mount effect: kick off the resume attempt for a non-settled tail. Marking the @@ -628,6 +792,9 @@ export default function ChatThread({ return () => { mountedRef.current = false; attachAbortRef.current?.abort(); + // #430: drop any pending reconnect backoff so it can't fire against the next + // chat this thread's refs are reused for. + if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); }; // Mount-only by design; the parent remounts per chat via `key`. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -666,12 +833,27 @@ export default function ChatThread({ if (tail.status !== "streaming") { reconcileTailRef.current = false; onResumeFallback?.(false); + // #430: the run reached its terminal state via the degraded poll — there is + // no live tail left to reconnect to, so drop any reconnect banner / Retry. + clearReconnectTimer(); + setReconnectStatePair(null); } // onResumeFallback intentionally omitted (parent-stable callback); deps are // fixed by the resume design. // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialRows, isStreaming, setMessages]); + // #430: a real stream is live again — the reconnect re-attached to the live tail + // (status -> "streaming") OR the user started a new local turn. Either way clear + // the reconnect banner + any pending backoff. Gated on "streaming" (not the + // broader "submitted") so a still-pending attach GET does not clear prematurely. + useEffect(() => { + if (status === "streaming") { + clearReconnectTimer(); + setReconnectStatePair(null); + } + }, [status, clearReconnectTimer, setReconnectStatePair]); + // "Send now" on a queued message: interrupt the current turn and immediately // send THIS message, keeping the agent's partial output. Other queued messages // stay queued and flush normally after the new turn. Reuses the existing @@ -719,6 +901,9 @@ export default function ChatThread({ // observer's Stop would otherwise leave the attach fetch running. attachAbortRef.current?.abort(); stop(); + // #430: pressing Stop also cancels an in-progress reconnect sequence. + clearReconnectTimer(); + setReconnectStatePair(null); if (!autonomousRunsEnabled) return; if (chatIdRef.current) { onServerStop?.(chatIdRef.current); @@ -740,7 +925,13 @@ export default function ChatThread({ // for this fix. Documented so a future change can address the abort-ordering. stopPendingRef.current = true; } - }, [stop, autonomousRunsEnabled, onServerStop]); + }, [ + stop, + autonomousRunsEnabled, + onServerStop, + clearReconnectTimer, + setReconnectStatePair, + ]); // Clear the stopped marker as soon as a new turn begins streaming, and drop any // stale "Send now" interrupt flags. On the legit interrupt path both refs are @@ -825,6 +1016,43 @@ export default function ChatThread({ detail={errorView.detail} mb="xs" /> + ) : reconnectState ? ( + // #430: while auto-reconnecting to a detached run's live tail, show progress + // instead of a dead "Lost connection" banner; once attempts are exhausted, + // offer a manual Retry (the degraded poll keeps catching up underneath). + + + {reconnectState.phase === "trying" ? ( + <> + + + {t("Connection lost — reconnecting…")} + {` (${reconnectState.attempt}/${RECONNECT_MAX_ATTEMPTS})`} + + + ) : ( + <> + + {t("Couldn't reconnect to the answer.")} + + + + )} + + ) : stopNotice ? ( 204). */ -export const RUN_STREAM_MAX_BUFFER_BYTES = 4 * 1024 * 1024; +/** + * Per-run replay buffer cap. Past this the buffer is dropped (attach -> 204, and + * the client falls back to its restore + degraded-poll path, #430). + * + * Raised from 4MB to 32MB (#430): marathon autonomous runs (11-25 min observed) + * stream far more than 4MB of SSE frames, so a live disconnect mid-run would find + * an already-overflowed buffer and could only degrade-poll instead of re-attaching + * to the live tail. 32MB comfortably covers those runs while staying bounded. + * + * Memory cost: this is the WORST-CASE retained size PER ACTIVE run (the buffer is + * freed on finish + retention, or dropped immediately on overflow). With the small + * number of concurrent autonomous runs a single workspace realistically has, 32MB + * each is an acceptable ceiling; the overflow->204->degraded-poll fallback remains + * the backstop for anything larger, so correctness never depends on this bound. + */ +export const RUN_STREAM_MAX_BUFFER_BYTES = 32 * 1024 * 1024; -// 2x the replay cap: a just-written 4MB replay burst alone can never trip the +// 2x the replay cap: a just-written full-replay burst alone can never trip the // per-subscriber cap (see controller); only a genuinely stalled socket can. export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * RUN_STREAM_MAX_BUFFER_BYTES; diff --git a/apps/server/src/core/ai-chat/ai-chat-stream-registry.spec.ts b/apps/server/src/core/ai-chat/ai-chat-stream-registry.spec.ts index 5d10d0cf..193b76b1 100644 --- a/apps/server/src/core/ai-chat/ai-chat-stream-registry.spec.ts +++ b/apps/server/src/core/ai-chat/ai-chat-stream-registry.spec.ts @@ -2,6 +2,7 @@ import { AiChatStreamRegistryService, RUN_STREAM_MAX_BUFFER_BYTES, RUN_STREAM_RETAIN_FINISHED_MS, + SUBSCRIBER_MAX_BUFFERED_BYTES, RunStreamCallbacks, } from './ai-chat-stream-registry.service'; @@ -210,9 +211,10 @@ describe('AiChatStreamRegistryService', () => { const att = (await registry.attach(CHAT, false, undefined, c.cb))!; att.start(); - const oneMb = 'x'.repeat(1024 * 1024); - // 5 x 1MB = 5MB > 4MB cap; the 5th frame is the one that crosses. - for (let i = 0; i < 5; i++) src.push(oneMb + i); + // Cap-relative so it survives a buffer-cap change (#430): a quarter-cap frame + // means 5 frames comfortably exceed the replay cap; the last one crosses. + const chunk = 'x'.repeat(Math.floor(RUN_STREAM_MAX_BUFFER_BYTES / 4)); + for (let i = 0; i < 5; i++) src.push(chunk + i); await flush(); const entry = (registry as any).entries.get(CHAT); @@ -220,7 +222,7 @@ describe('AiChatStreamRegistryService', () => { expect(entry.bytes).toBeGreaterThan(RUN_STREAM_MAX_BUFFER_BYTES); // The live subscriber received ALL 5 frames, including the crossing one. expect(c.frames).toHaveLength(5); - expect(c.frames[4]).toBe(oneMb + 4); + expect(c.frames[4]).toBe(chunk + 4); // A NEW attach after overflow gets null (replay buffer is gone). const c2 = collector(); @@ -240,9 +242,11 @@ describe('AiChatStreamRegistryService', () => { const attB = (await registry.attach(CHAT, false, undefined, b.cb))!; attB.start(); - const oneMb = 'x'.repeat(1024 * 1024); - // 9 x 1MB = 9MB > 8MB per-subscriber cap; A's pending overflows, B streams live. - for (let i = 0; i < 9; i++) src.push(oneMb + i); + // Cap-relative so it survives a buffer-cap change (#430): a quarter-of-the- + // per-subscriber-cap frame means 5 frames exceed A's paused-pending cap while + // B streams every frame live. + const chunk = 'x'.repeat(Math.floor(SUBSCRIBER_MAX_BUFFERED_BYTES / 4)); + for (let i = 0; i < 5; i++) src.push(chunk + i); await flush(); const entry = (registry as any).entries.get(CHAT); @@ -250,7 +254,7 @@ describe('AiChatStreamRegistryService', () => { expect(entry.subscribers.size).toBe(1); expect(a.frames).toEqual([]); // paused + overflowed: nothing was delivered // B received every frame live (delivery unaffected by A's overflow). - expect(b.frames).toHaveLength(9); + expect(b.frames).toHaveLength(5); // A's start() (arriving late) degrades to an immediate end, not a partial replay. attA.start();