phase: 120_failed_turn_retry
Build and Push Containers / build-and-push-app (push) Successful in 3m5s
Build and Push Containers / build-and-push-db (push) Failing after 2m13s

All verification complete. Final report:

**Phase 120 — Failed-turn retry: verification pass (all 3 tasks were done; final verification + 1 regression fix)**

**Verified:** `ChatMessage.failed`/`error` (≤500, `extra="forbid"` intact); `finalizeFailedTurn` funnel on the 3 failure paths (catch-else, stream-drop guard, zero-frame fallback) with `failed: true` + capped detail + `markLastRetryable`; `appendFailedNote` restore branch (Save-as-doc/Tune excluded); `showErrorBanner`/`retryLastTurn` byte-pinned untouched; only the three paths persist `failed: true` (grep + unit pin); no test asserts the old broken behavior.

**Defect found & fixed (rule 7):** a real navigate-away mid-turn let the browser's teardown fetch rejection (TypeError, not AbortError) leak into the failed funnel, persisting a phantom failed brain record — `test_sources_midstream_bug.py::test_no_orphan_brain_message_when_navigated_before_first_token` failed (2 `.msg` after reload) and violated the phase-20 navigate-away convention. Fixed: turn-scoped `leftThePage` flag (set unconditionally on `pagehide`, reset in `runTurn`) skips the funnel in the catch-else branch; pinned by new unit test `test_navigate_away_is_not_a_failed_turn`. No phase-overview/PLAN/todo/complete files touched; no commits made.

**Gates (exact):**
- `uv run pytest` → 2577 passed
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 4271 stmts, 99% (>90%)
- `uv run pytest tests/e2e/test_failed_turn_retry.py -v --no-cov` → 4 passed (isolated)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- Regression E2E, isolated: `test_sources_midstream_bug.py` 6/6 (was 5/6); `test_llm_retry`/`test_tool_scaffolding_guardrails`/`test_stop_generation`/`test_navbar_refresh` 17/17

**Completion criteria:** (1) network error → banner + in-bubble Retry, re-ask without re-typing ✅ (E2E A); (2) refresh restores failed bubble + working Retry, no "new chat" ✅ (E2E C); (3) stopped/successful turns byte-identical ✅ (negative E2E, stop suite, byte-identity units); (4) pytest/coverage/lint/types ✅; (5) commit + phase move — left to the harness per pass rules.

**Notable:** deviation = the regression fix above (a navigation is not a failed turn; phase-20 partial-persist convention restored). Next pending phase: `121_git_source_tokens`.
This commit is contained in:
2026-09-24 18:50:36 -04:00
parent 0ff1f8c4d6
commit 3a0fc3db05
29 changed files with 2298 additions and 28 deletions
+198 -10
View File
@@ -364,6 +364,15 @@ const ERROR_HINT = "If this persists, check the LLM is reachable.";
exhausted max_tokens — phase 17) still renders a bubble, and this exact
text is what gets persisted: what the user saw is what is stored. */
const EMPTY_ANSWER_FALLBACK = "Hmm — that came back empty. Ask me again?";
/* Phase 120 (TODO.md L3–4, locked A1): the fixed bubble text of a FAILED
turn that streamed ZERO frames (network error, pre-stream HTTP error) —
a short honest "my answer didn't make it" line. NOT the
EMPTY_ANSWER_FALLBACK: that constant is the zero-frame-but-COMPLETED
case's answer text (the stream settled; a failed turn didn't). The real
detail rides the in-bubble .failed-note (appendFailedNote) and the
persisted `error` key (capped at 500 — the ChatMessage bound). */
const FAILED_TURN_TEXT =
"My answer didn't make it — the connection dropped. Use Retry to ask again.";
/* Calm, don't remove: smooth scrolling is the one motion JS controls. */
const reducedMotion =
@@ -504,6 +513,48 @@ function appendStoppedNote(wrap) {
meta.appendChild(note);
}
/* Phase 120 (TODO.md L3–4): the "Failed" note in the meta row of a failed
* brain bubble — the mirror of appendStoppedNote for the phase-120 failed
* turn (network error, SSE error frame, stream drop). The live failure
* paths (finalizeFailedTurn / the zero-frame fallback) and the phase-14
* restore path (a record with `m.failed`) share this helper, so a restored
* bubble reads exactly like the failed one. The banner keeps its
* role="alert" summary; this in-bubble note is the refresh-surviving copy
* (on restore it renders from the persisted `error` detail). Reuses the
* .msg-meta row the way appendStoppedNote does (role=list → the span joins
* as a listitem so ARIA stays valid); the triangle glyph is aria-hidden
* decoration — the "Failed" label + the detail text carry the accessible
* meaning (text + color, never color alone — B5). `detail` goes through
* textContent (no HTML from the error string, ever). */
const FAILED_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>';
function appendFailedNote(wrap, detail) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".failed-note")) return; // one per bubble
const note = document.createElement("span");
note.className = "failed-note";
if (meta.getAttribute("role") === "list") note.setAttribute("role", "listitem");
note.innerHTML = FAILED_ICON;
const label = document.createElement("span");
label.textContent = "Failed";
note.appendChild(label);
if (detail) {
const d = document.createElement("span");
d.className = "failed-detail";
d.textContent = detail; // the error string is NEVER innerHTML
note.appendChild(d);
}
meta.appendChild(note);
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the rendered wrap of
* the CURRENT last brain record. Set wherever a brain bubble becomes the
* latest persisted answer (the `done` branch, the empty-answer fallback,
@@ -1182,6 +1233,19 @@ let toolLineWrap = null; // the live wrap the clock suffixes (null when stopped)
let acc = ""; // accumulated answer text this turn
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
let persistedOnLeave = false; // pagehide partial-persist at most once
/* Phase 120 verification (navigate-away regression): a pagehide has
* fired. A REAL departure tears the document down and the browser
* cancels the in-flight fetch — that teardown rejection (a TypeError,
* NOT an AbortError) must not be read as a FAILED turn by the error
* catch's else branch: the phase-20 convention is that a thinking-only
* navigate-away persists NOTHING brain-side (the question is already
* saved on send — the user can re-ask) and a partial navigate-away
* persists the pagehide partial, not a failed record. Set
* unconditionally on pagehide — a merely-hidden tab in some browsers
* also fires it, but there the stream keeps arriving (no rejection
* follows), so the flag stays inert — and reset per turn like the
* other turn locals. */
let leftThePage = false;
/* Phase 73 (task 02, TODO.md L3): the pagehide partial is CORRELATED with
* the turn's settle. `leavePartialIndex` is the index in `conversation`
* of the brain record the `pagehide` handler pushed for THIS turn (else
@@ -1546,7 +1610,8 @@ function appendMaybeTry(wrap, suggestions) {
* bor.chat.v1 → { v: 1, chatId: string | null,
* messages: [{ who: "user"|"brain", text,
* sources?, deflected?, suggestions?,
* thinking?, tools?, stopped? }] }
* thinking?, tools?, stopped?,
* failed?, error? }] }
*
* Only RAW TEXT is stored — restore re-renders it through the escape-first
* markdown renderer, so no HTML is ever persisted. Save points: the user
@@ -1680,12 +1745,28 @@ function renderStoredMessage(m) {
appendMaybeTry(wrap, m.suggestions);
}
appendSources(wrap, m.sources);
appendTuneButton(wrap); // restored brain answers are tunable too
// Phase 120: a failed turn (m.failed) is a note, not an answer — no
// Tune (the "note, not an answer" scope the m.stopped exclusion
// below shares; the live failure paths add no Tune either). Stopped
// partials keep their button — byte-identical to pre-phase-120.
if (!m.failed) appendTuneButton(wrap); // restored brain answers are tunable too
// Phase 59: the RAW persisted markdown (m.text — HTML is never
// persisted). A stopped partial (m.stopped) is a note, not an answer
// — no button (the live stop path adds none either).
if (!m.stopped) appendSaveAsDocButton(wrap, m.text);
// persisted). A stopped partial (m.stopped) or a failed turn
// (m.failed) is a note, not an answer — no button (the live
// stop/failure paths add none either).
if (!m.stopped && !m.failed) appendSaveAsDocButton(wrap, m.text);
if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores
// Phase 120: the failed marker restores — the in-bubble error note
// re-renders from the persisted `error` detail (the note is absent
// when it is null: the record's `text` already carries the detail).
// No other restore change: the lastBrainWrap assignment below + the
// restore loop's markLastRetryable() already land the Retry button
// on the LAST restored brain bubble — the failed one when it ends
// the conversation — so the refreshed page is the same retryable
// state the live error was (retryLastTurn re-asks the question that
// precedes the failed record, unchanged; a mid-conversation failed
// bubble gets no button — the phase-49 last-bubble-only rule).
if (m.failed && m.error) appendFailedNote(wrap, m.error);
// Phase 113 (task 02): the related tier restores with the bubble
// (LAST — after the meta-row claimers, exactly like the live done
// path). Pre-phase records carry no `related` → appendRelated
@@ -2333,6 +2414,67 @@ async function handleSend(e) {
await runTurn(text, { reask: false });
}
/* Phase 120 (TODO.md L3–4, locked A1): the single funnel for every
* NON-STOP, NON-ABORT turn failure — the SSE error-frame catch's else
* branch (network error, pre-stream HTTP error, the `error` frame's
* throw) and the stream-drop guard (frames arrived, no `done`). A failed
* turn persists as a BRAIN record marked `failed: true` + the capped
* error detail (the phase-48 `stopped` precedent — no separate error
* table, no new API): the question's user record immediately precedes it,
* so retryLastTurn's pop-the-last-brain-record-then-re-ask logic works
* UNCHANGED, and lastBrainWrap is set BEFORE the caller's
* setUiState(UI_STATE.error, …) — so showErrorBanner's EXISTING
* `opts.retryable && lastBrainWrap` condition finally reveals the
* phase-111 banner Retry on a network error (showErrorBanner itself is
* byte-unchanged).
*
* Two shapes: a partial `wrap` exists (a frame streamed) — close the
* thinking block + tool calls (the stop-finalize pattern), keep the
* streamed answer text, add the in-bubble error note; or zero frames —
* a new FAILED_TURN_TEXT bubble. Either way the record rides
* rememberBrainTurn (localStorage + the phase-55 auto-save) and the
* in-bubble Retry button lands on the failed bubble (markLastRetryable).
* `detail` is trimmed + capped at 500 before persistence (the schema's
* ChatMessage.error bound is the backstop; the banner keeps the original
* untrimmed string). NOT this funnel: the stop path (its own branch),
* the 300s abort (the `aborted` branch), and the zero-frame-but-completed
* case (the stream settled — runTurn's inline fallback branch keeps its
* EMPTY_ANSWER_FALLBACK bubble and only gains the failed marker). */
function finalizeFailedTurn(detail, { acc, thinking, tools, wrap, leavePartialIndex }) {
const error = (detail || "").trim().slice(0, 500);
if (wrap) {
// Partial streamed: keep the answer text the user saw, settle the
// block + calls closed (the stop-finalize pattern), mark it failed.
closeThinkingBlock(wrap);
closeToolCalls(wrap);
appendFailedNote(wrap, error);
rememberBrainTurn(
acc,
{
thinking: thinking || undefined,
tools: tools.length ? tools : undefined,
failed: true,
error: error || undefined,
},
leavePartialIndex
);
lastBrainWrap = wrap;
} else {
// Zero frames (network error, pre-stream HTTP error): a new failed
// bubble with the fixed honest line — the real detail rides the note
// + the persisted `error` key.
const fwrap = addMessage("brain", FAILED_TURN_TEXT);
appendFailedNote(fwrap, error);
rememberBrainTurn(
FAILED_TURN_TEXT,
{ failed: true, error: error || undefined },
leavePartialIndex
);
lastBrainWrap = fwrap;
}
markLastRetryable(); // the in-bubble Retry lands on the failed bubble
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the chat turn —
* extracted from handleSend so the retry redo can re-run a question
* without re-adding it. `reask` skips (a) the user-bubble append and
@@ -2367,6 +2509,7 @@ async function runTurn(text, { reask = false } = {}) {
acc = "";
thinkingAcc = "";
persistedOnLeave = false;
leftThePage = false;
leavePartialIndex = -1;
// Phase 48: a fresh abort owner per turn (cleared in the finally); the
// stop flag resets with the rest of the turn locals.
@@ -2654,20 +2797,40 @@ async function runTurn(text, { reask = false } = {}) {
// idle with a half bubble. The zero-frame case falls through to the
// existing empty-answer fallback below.
if (!sawDone && !aborted && (acc || thinkingAcc)) {
setUiState(
UI_STATE.error,
"The stream ended before my answer finished — try again?"
);
// Phase 120 (TODO.md L3–4): a half-answer is a FAILED answer — the
// partial persists as failed (its text + the in-bubble error note +
// a working Retry) BEFORE the error state, so Refresh restores what
// the user saw + a Retry (no bare question, no "new chat").
const detail = "The stream ended before my answer finished — try again?";
finalizeFailedTurn(detail, {
acc,
thinking: thinkingAcc,
tools: toolAcc,
wrap,
leavePartialIndex,
});
setUiState(UI_STATE.error, detail);
}
if (!aborted && !wrap) {
const fallback = EMPTY_ANSWER_FALLBACK;
// Phase 120 (TODO.md L3–4, task 01 ASSUMPTION): the stream
// COMPLETED with ZERO frames — it is a failed turn too. The bubble
// text stays EMPTY_ANSWER_FALLBACK (a meaningful record text); the
// failed marker + error note make it persist + restore as a failure
// with a working Retry (the pre-phase record persisted with no
// marker — inconsistent with the refresh case this phase fixes).
const nothing = "The model answered with nothing.";
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
appendFailedNote(fwrap, nothing);
// Phase 73: correlated like the other settles — unreachable when a
// pagehide partial exists (that needs acc, which means a wrap), but
// passed so EVERY settle write goes through the same correlation.
rememberBrainTurn(fallback, {}, leavePartialIndex); // persist what the user actually saw
rememberBrainTurn(fallback, {
failed: true,
error: nothing,
}, leavePartialIndex); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
}
@@ -2708,6 +2871,30 @@ async function runTurn(text, { reask = false } = {}) {
err instanceof Error && err.message
? err.message
: "Something went wrong on my side.";
// Phase 120 (TODO.md L3–4, locked A1): persist the failed turn
// BEFORE the error state — the funnel sets lastBrainWrap, so the
// existing `opts.retryable && lastBrainWrap` condition in
// setUiState → showErrorBanner now reveals the phase-111 banner
// Retry on a network error (the button's precondition finally
// holds; showErrorBanner itself is byte-unchanged).
// Phase 120 verification (navigate-away regression): a REAL
// departure (pagehide fired) tears the document down and the
// browser cancels the in-flight fetch — that teardown rejection
// is NOT a failed turn. The phase-20 convention stands: a
// thinking-only navigate-away persists NOTHING brain-side, and a
// partial navigate-away persists the pagehide partial (above) as
// a plain record — never a failed one. A real network error
// while the page is still alive (no pagehide) takes the funnel
// as before.
if (!leftThePage) {
finalizeFailedTurn(detail, {
acc,
thinking: thinkingAcc,
tools: toolAcc,
wrap,
leavePartialIndex,
});
}
// Phase 114 (TODO L6): the SSE error frame's optional hint flows to
// the banner (setUiState → showErrorBanner's opts.hint); the
// phase-111 Retry button rides along on the same turn-error path.
@@ -2776,6 +2963,7 @@ staleRegenBtn?.addEventListener("click", regenerateStaleChat);
* C1). A REAL navigation (the page actually unloads) never runs a settle,
* so the partial stays persisted exactly as before. */
window.addEventListener("pagehide", () => {
leftThePage = true; // the dying fetch's teardown rejection is not a failed turn
if (persistedOnLeave) return;
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming)
return;