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
+19 -2
View File
@@ -744,8 +744,8 @@ class ChatMessage(BaseModel):
(phase 14) — the stored ``messages`` payload of a saved chat (phase 50).
``{who, text, sources?, related?, deflected?, suggestions?,
thinking?, tools?, stopped?}`` — raw text, never HTML, so a saved
chat restores pixel-identical through the existing
thinking?, tools?, stopped?, failed?, error?}`` — raw text, never
HTML, so a saved chat restores pixel-identical through the existing
``renderStoredMessage`` path.
``extra="forbid"`` rejects unknown keys (a corrupted or HTML-shaped
payload, e.g. a stray ``<b>``-ish extra key) at the boundary with a
@@ -774,6 +774,19 @@ class ChatMessage(BaseModel):
shape; ``suggestions`` ≤ 50 chips of ≤ 200 chars; ``tools``
≤ 50 — one entry per tool call, the round cap is 10). Only value
bounds were added — the accepted/rejected KEYS are unchanged.
Phase 120 (task 01, locked A1): a FAILED turn (network error, SSE
``error`` frame, stream drop — the client-side failure paths) stores
``{who: "brain", text: <detail or fallback>, failed: true,
error: <detail>}`` — the phase-48 ``stopped`` precedent: a marker +
the capped detail on the brain record itself, no separate error
table, no new API (``retryLastTurn``'s pop-the-last-brain-record
logic works on a failed record UNCHANGED — the question's user
record immediately precedes it). ``error`` is the persisted banner
detail, capped at 500 (the phase-83 value-bounds style). No
serializer change: ``None`` values flow as absent/None exactly like
``stopped`` today — the phase-50 byte-identical round-trip contract
covers the new keys automatically.
"""
model_config = ConfigDict(extra="forbid")
@@ -787,6 +800,10 @@ class ChatMessage(BaseModel):
thinking: str | None = Field(default=None, max_length=32_000)
tools: list[ToolCall] | None = Field(default=None, max_length=50)
stopped: bool | None = None
# Phase 120 (task 01, locked A1): the failed-turn marker + the
# persisted error detail (the phase-48 ``stopped`` precedent).
failed: bool | None = None
error: str | None = Field(default=None, max_length=500)
class SavedChatCreate(BaseModel):