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
+16 -16
View File
@@ -34,10 +34,10 @@ mid-stream window) the pins are:
question with NO dispatched events completes identically (guards
against an over-eager fix changing the normal path).
4. ``test_pre_token_guard_survives_hidden_window`` — task 01's C2
(confirmed): hidden time must not count toward the 120 s pre-token
(confirmed): hidden time must not count toward the 300 s pre-token
guard. Playwright's fake clock makes the guard's deadline
deterministic: the clock is fast-forwarded past the guard's
ORIGINAL 120 s deadline while the tab is (synthetically) hidden,
ORIGINAL 300 s deadline while the tab is (synthetically) hidden,
with a return to visible in between — the phase-73 re-arm gives the
still-armed guard a FRESH ``TURN_TIMEOUT_MS``, so the turn still
settles with the answer. Without the re-arm the fast-forwarded
@@ -91,7 +91,7 @@ LONG_ANSWER = long_answer()
LONG_ANSWER_END = "LONG-ANSWER-END"
#: The mock's only pure pre-token silence (phase-06 phrasing): a 3 s
#: delay before the FIRST frame, so the 120 s pre-token guard stays
#: delay before the FIRST frame, so the 300 s pre-token guard stays
#: armed the whole window (no thinking/delta/retry frame to clear it).
SLOW_QUESTION = "pretend to think slowly then tell me about kubernetes"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
@@ -435,29 +435,29 @@ def test_baseline_no_pagehide_still_completes(
def test_pre_token_guard_survives_hidden_window(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The 120 s pre-token guard is a plain ``setTimeout`` — without the
"""The 300 s pre-token guard is a plain ``setTimeout`` — without the
phase-73 visibility re-arm, hidden time counts toward it and a turn
whose first frame lands past the 120 s mark errors with the "stuck"
whose first frame lands past the 300 s mark errors with the "stuck"
copy (task 01 scenario B, C2 confirmed). The fake clock makes the
deadline deterministic (waiting 120 s of real time is not an
deadline deterministic (waiting 300 s of real time is not an
option for a regression suite): the mock's 3 s real-time pre-token
silence keeps the guard armed while the FAKE clock is fast-forwarded
across the original deadline — with a return to visible in between,
which re-arms the still-armed guard with a fresh TURN_TIMEOUT_MS.
Timeline (fake clock, the guard armed at t=0, deadline t=120 s):
Timeline (fake clock, the guard armed at t=0, deadline t=300 s):
* t=0 — the slow question is sent; the guard arms (t=120 s);
* t=0 — the slow question is sent; the guard arms (t=300 s);
* t~0 — the tab goes hidden (synthetic; pre-token, so the
pagehide handler persists nothing — the question is
already saved);
* t=115 — fast-forward while hidden: the original timer (120 s)
* t=295 — fast-forward while hidden: the original timer (300 s)
is not due yet;
* t=115 — back to visible: the re-arm fires (the guard is still
armed — no frame has arrived) → new deadline t=235 s;
* t=229 — fast-forward again: PAST the original 120 s deadline —
* t=295 — back to visible: the re-arm fires (the guard is still
armed — no frame has arrived) → new deadline t=595 s;
* t=585 — fast-forward again: PAST the original 300 s deadline —
without the re-arm the guard fires HERE with the "stuck"
error — but short of the re-armed 235 s deadline.
error — but short of the re-armed 595 s deadline.
Meanwhile (real time, ~3.5 s after the send) the mock's first frame
lands, clears the guard for good, and the turn settles with the
@@ -467,7 +467,7 @@ def test_pre_token_guard_survives_hidden_window(
page.set_default_timeout(30_000)
# The fake clock BEFORE the first navigation: every app timer (the
# 120 s guard among them) becomes test-controlled, while the mock's
# 300 s guard among them) becomes test-controlled, while the mock's
# real-time stream is unaffected (the network is not a timer).
page.clock.install()
login(page, app_url, next="/") # phase 79: chat is require_user-gated
@@ -481,9 +481,9 @@ def test_pre_token_guard_survives_hidden_window(
expect(page.locator(ANSWER)).to_have_count(0)
page.evaluate(HIDE_JS)
page.clock.fast_forward(115_000)
page.clock.fast_forward(295_000)
page.evaluate(SHOW_JS) # the re-arm: a fresh TURN_TIMEOUT_MS
page.clock.fast_forward(114_000) # past 120 s, short of the re-armed 235 s
page.clock.fast_forward(290_000) # past 300 s, short of the re-armed 595 s
# The turn still settles with the full answer — no "taking a long
# time" error, no aborted stream.
+10 -10
View File
@@ -27,13 +27,13 @@ def _html() -> str:
return (FRONTEND / "index.html").read_text(encoding="utf-8")
def test_turn_timeout_constant_exported_at_120s() -> None:
"""The 120s client-side guard (PLAN §7.4) must be an *exported*
def test_turn_timeout_constant_exported_at_300s() -> None:
"""The 300s client-side guard (PLAN §7.4) must be an *exported*
constant — testable, and the single value the E2E timeout story keys
off."""
js = _js()
match = re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*120_?000\s*;", js)
assert match, "app.js must export `const TURN_TIMEOUT_MS = 120000`"
match = re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*300_?000\s*;", js)
assert match, "app.js must export `const TURN_TIMEOUT_MS = 300000`"
def test_state_machine_has_all_four_states() -> None:
@@ -69,7 +69,7 @@ def test_typing_indicator_contract_strings() -> None:
def test_error_banner_has_actionable_hint() -> None:
"""Every error path (SSE error event, non-2xx, 120s timeout) must
"""Every error path (SSE error event, non-2xx, 300s timeout) must
surface the same actionable retry hint (story AC4)."""
js = _js()
assert "check the LLM is reachable" in js
@@ -118,7 +118,7 @@ def test_busy_button_style_tokens() -> None:
def test_thinking_event_is_a_first_class_turn_branch() -> None:
"""Phase 17: `thinking` SSE frames stream live into the collapsible
Thinking block — the typing dots make way, the 120s pre-token guard
Thinking block — the typing dots make way, the 300s pre-token guard
clears (the stream is alive), and the text renders through the
escape-first markdown renderer (XSS-safe). While open, the stream is
pinned to the bottom of the block."""
@@ -129,7 +129,7 @@ def test_thinking_event_is_a_first_class_turn_branch() -> None:
branch = js[thinking_idx:delta_idx]
assert "thinkingAcc += ev.text" in branch
assert "sawThinking = true" in branch
assert "clearTurnTimeout()" in branch, "first thinking frame clears the 120s guard"
assert "clearTurnTimeout()" in branch, "first thinking frame clears the 300s guard"
assert "removeTyping()" in branch, "the live block replaces the typing dots"
assert "ensureThinkingBlock(wrap)" in branch
assert "renderMarkdown(thinkingAcc)" in branch, "escape-first renderer (XSS-safe)"
@@ -232,7 +232,7 @@ def test_in_flight_button_is_the_stop_control() -> None:
def test_abort_plumbing_owns_the_fetch() -> None:
"""The in-flight fetch is owned by an AbortController created at
turn start (module scope, cleared in the finally), passed to the
fetch as its signal; the 120s guard aborts the same controller as
fetch as its signal; the 300s guard aborts the same controller as
its backstop — with `aborted = true` FIRST, so the catch never reads
the guard's abort as a user stop (one owner, same outcome)."""
js = _js()
@@ -614,7 +614,7 @@ def test_retry_frame_is_a_first_class_branch_between_tool_and_delta() -> None:
"""Phase 67 (owner-locked A4, task 02's contract): a `retry` SSE
frame (the server restarted the LLM request before its first piece
— locked A2) is a first-class branch in runTurn's handler, ordered
BETWEEN the `tool` and `delta` branches. It clears the 120s guard
BETWEEN the `tool` and `delta` branches. It clears the 300s guard
(a frame arrived), resolves n/N from the frame, and writes the
owner-locked copy literal onto the EXISTING channels only — the
#send-status live region + the typing-indicator aria-label. No DOM
@@ -630,7 +630,7 @@ def test_retry_frame_is_a_first_class_branch_between_tool_and_delta() -> None:
)
assert js.count('ev.type === "retry"') == 1, "exactly one retry branch"
branch = js[retry_idx:delta_idx]
assert "clearTurnTimeout()" in branch, "a frame arrived — the 120s guard clears"
assert "clearTurnTimeout()" in branch, "a frame arrived — the 300s guard clears"
assert "Number(ev.attempt)" in branch, "n = the attempt about to be tried"
assert "Number(ev.max_attempts)" in branch, "N = the configured total"
assert (
+2 -2
View File
@@ -152,8 +152,8 @@ def test_real_navigation_behavior_is_unchanged() -> None:
# The phase-48 teardown contract (real close / navigation / Stop still
# aborts the fetch) is untouched.
assert "turnAbort?.abort()" in js, "the abort owner still aborts the fetch"
assert re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*120_?000\s*;", js), (
"the guard constant is still 120s (owner-locked phase-17/48 value)"
assert re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*300_?000\s*;", js), (
"the guard constant is still 300s (raised from the phase-17/48 value)"
)