fix(chat): raise pre-token guard from 120 s to 300 s
Build and Push Containers / build-and-push-app (push) Successful in 1m48s
Build and Push Containers / build-and-push-db (push) Successful in 15s

The client-side TURN_TIMEOUT_MS was the binding constraint: turns with
slow prompt processing (no first SSE frame within 120 s of visible time)
errored with the 'stuck' copy even though nginx (300 s) and
BOR_LLM_TIMEOUT (300 s) would have let them run. Raise the guard to
300 s so the upstream timeouts are reachable, and re-pin the tests:
the unit constant pins and the fake-clock E2E timeline (295 s hidden
+ 290 s after the re-arm = 585 s: past the original 300 s deadline,
short of the re-armed 595 s deadline).

Verified: tests/unit (full, 100% pass), tests/e2e/test_hidden_tab_stream.py
and tests/e2e/test_loading_feedback.py in isolation.
This commit is contained in:
2026-09-08 09:37:33 -04:00
parent 7cfe58fb21
commit 5abe8871e3
4 changed files with 39 additions and 39 deletions
+11 -11
View File
@@ -22,7 +22,7 @@
* SSE events), the live collapsible Thinking block IS the
* visible feedback (it replaces the typing dots; the UI
* state stays "thinking" — the button stays the "Stop"
* control) and the 120s guard clears on the first
* control) and the 300s guard clears on the first
* thinking *or* delta event.
* • streaming — the first delta removes the dots and appends live into
* the answer bubble (auto-collapsing the Thinking block,
@@ -39,7 +39,7 @@
* SAME finally path — no error banner, no scroll
* (phase 42).
* • error — red banner (role="alert") with an actionable retry hint;
* the 120s guard (TURN_TIMEOUT_MS) catches hung pre-token
* the 300s guard (TURN_TIMEOUT_MS) catches hung pre-token
* streams and the sawDone guard (phase 17) catches a
* stream that dies after frames but before `done`, so the
* button can never sit zombified.
@@ -85,7 +85,7 @@
* request (up to BOR_LLM_RETRIES retries, BOR_LLM_RETRY_DELAY seconds
* apart — a flat delay, no backoff, owner-locked A3) and streams one
* `retry` SSE frame per wait. The handler treats it with the same
* status pattern as the `tool` frames: the 120s guard clears (a frame
* status pattern as the `tool` frames: the 300s guard clears (a frame
* arrived) and the EXISTING #send-status live region + the
* typing-indicator aria-label read the owner-locked copy
* "Communication interrupted — retrying (n of N)…" (n = the attempt
@@ -255,7 +255,7 @@
* question even on browsers that fire pagehide on a merely-hidden tab
* (e.g. Safari; Chromium fires only visibilitychange — task 01); a REAL
* navigation never runs a settle, so the leave-save is unchanged there.
* (2) The 120s pre-token guard counts only VISIBLE time: when the tab
* (2) The 300s pre-token guard counts only VISIBLE time: when the tab
* returns to visible with the guard still armed (it clears on the first
* thinking/delta/retry frame), it re-arms with a fresh TURN_TIMEOUT_MS —
* a slow first frame arriving while the tab was hidden past the deadline
@@ -305,7 +305,7 @@ const brand = () => window.BOR_BRAND || "Brain of Reese";
* cleared on the first delta (entering "streaming") and on every
* terminal transition. Exported so the constant is testable (tests/unit/
* test_frontend_feedback.py). */
export const TURN_TIMEOUT_MS = 120_000;
export const TURN_TIMEOUT_MS = 300_000;
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
* threshold (A5): below 5s a frameless gap reads as normal latency;
@@ -1023,7 +1023,7 @@ async function loadHealth() {
let uiState = UI_STATE.idle;
let thinkingClock = 0; // setInterval id — elapsed-seconds hint
let thinkingStart = 0; // Date.now() when "thinking" began
let turnTimeout = 0; // setTimeout id — 120s pre-token guard
let turnTimeout = 0; // setTimeout id — 300s pre-token guard
let turnTimeoutCb = null; // the guard's callback — lets visibilitychange re-arm it (phase 73)
/* Phase 87 (TODO.md L5): the per-tool-line elapsed clock — one clock per
* turn, re-armed per `tool` frame: the baseline (toolLineStart) resets on
@@ -1057,7 +1057,7 @@ let persistedOnLeave = false; // pagehide partial-persist at most once
let leavePartialIndex = -1; // index of this turn's pagehide partial (-1 = none)
/* Phase 48 (2026-08-29, TODO.md L3): the user-stop machinery.
* `turnAbort` owns the in-flight fetch (the Stop button aborts it; the
* 120s guard aborts the same controller as its backstop); `stoppedByUser`
* 300s guard aborts the same controller as its backstop); `stoppedByUser`
* marks a turn the Stop button took, so the catch's AbortError can tell a
* user stop from the guard's own abort (the guard sets `aborted` first).
* Both are turn-scoped: created/reset at the top of handleSend, cleared
@@ -1159,9 +1159,9 @@ function stopToolLineClock() {
}
/* Phase 73 (task 02; task 01 C2 — confirmed): a merely-HIDDEN tab must
* never stop a turn, but the 120s pre-token guard is a plain setTimeout,
* never stop a turn, but the 300s pre-token guard is a plain setTimeout,
* so hidden time counted toward it: a turn whose first frame lands while
* the tab is hidden past the 120s mark errored with the "stuck" copy
* the tab is hidden past the 300s mark errored with the "stuck" copy
* (the user perceives "switching tabs killed the answer"). When the tab
* returns to VISIBLE with the guard still armed (it is only armed in the
* pre-token window — cleared on the first thinking/delta/retry frame and
@@ -2089,7 +2089,7 @@ async function runTurn(text, { reask = false } = {}) {
let wrap = null;
let res = null;
let aborted = false; // the 120s guard already took the turn to error
let aborted = false; // the 300s guard already took the turn to error
// Phase 20: acc / thinkingAcc / persistedOnLeave live at module scope
// (the pagehide handler reads them) but reset here, so they stay
// turn-scoped exactly like the other turn locals. Phase 73:
@@ -2339,7 +2339,7 @@ async function runTurn(text, { reask = false } = {}) {
}
} catch (err) {
if (aborted) {
// The 120s guard already took the turn to the error state — its
// The 300s guard already took the turn to the error state — its
// own abort surfaces here, and it is never read as a user stop.
} else if (stoppedByUser || err?.name === "AbortError") {
// Phase 48 (owner-locked): the STOP path — no error banner. When