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
+33
View File
@@ -898,6 +898,39 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
.stopped-note svg { width: 10px; height: 10px; display: block; fill: currentColor; }
/* Phase 120 (TODO.md L3–4): the "Failed" note in a failed brain
bubble's meta row — the .stopped-note family in the theme's error
color (text + color, never color alone — B5: the "Failed" label
carries the meaning, the triangle glyph is aria-hidden decoration in
the JS). --err-ink on the --surface bubble ≈9.0:1 (the same value as
--brand-ink, computed 9.0:1 on --surface above) — well past 4.5:1
(WCAG 2.1 AA); the monochrome theme grays it automatically (token,
not literal — phase-92 zero-literal). Non-interactive — no hover, no
focus (pointer-events: none), the stopped note's way. The detail
span WRAPS (the stopped note's nowrap fits a one-word label; a 500-
char error detail must not blow out the 46rem chat column) —
overflow-wrap: anywhere keeps long unbroken details inside the row. */
.failed-note {
display: inline-flex;
align-items: flex-start;
gap: 0.3rem;
max-width: 100%;
color: var(--err-ink);
font-size: 0.75rem;
font-weight: 600;
pointer-events: none;
}
.failed-note svg {
width: 10px;
height: 10px;
margin-top: 0.15rem;
display: block;
flex: none;
fill: none;
stroke: currentColor;
}
.failed-detail { font-weight: 400; overflow-wrap: anywhere; }
/* Inline tuning form under the bubble: labeled textarea + Save/Cancel
(both ≥44px). Save = brand button (dark ink 5.2:1), Cancel = ghost. */
.tune-form {