phase: 96_oneshot_resilience
Build and Push Containers / build-and-push-app (push) Successful in 1m34s
Build and Push Containers / build-and-push-db (push) Successful in 10s

All checks complete. Final report:

**Phase 96 (one-shot resilience) — final verification pass, all green** (all 4 task files already in `complete/`; verified the working-tree implementation against the design)

- `LLMClient.chat()` empty-content retry (D1–D3) via `_chat_once` + `_EmptyContentError` (carries `finish_reason`), under `BOR_LLM_RETRIES`/`BOR_LLM_RETRY_DELAY` — verified in diff
- `missing_folder_summaries()` + `generate_folder_summaries(only_missing=…)` — verified; `folder_summary_table_empty` deleted, both sync gates switched to the gap probe
- `.env.example` comments updated (chat-turn stream + one-shot summary calls)

**Test / lint / coverage results**
- `uv run pytest --cov=app --cov-report=term-missing` → **1988 passed**, coverage **99%** (gate >90%)
- `uv run pytest tests/e2e/test_oneshot_llm_retry.py -v --no-cov` → **2 passed** (isolated)
- Regressions, each isolated: `test_ls_tree_drilldown` 3 passed, `test_sync_button` 3 passed, `test_local_directory_sources` 3 passed, `test_llm_retry` 4 passed
- `uv run ruff check . && uv run pyright` → clean (0 errors)

**Completion criteria:** retry-then-recover unit-pinned ✓ · exhaustion + `BOR_LLM_RETRIES=0` byte-identical ✓ · streaming path untouched ✓ · gap-fill both sync paths, other rows byte-identical incl. `updated_at` ✓ · no-gap zero-burn ✓ · phase E2E green ✓ · regression E2Es green ✓ · full suite + >90% + lint/types ✓ · no completed-phase behavior change (full suite green) ✓. Commit left to the harness per executor rules (working tree, 16 files).

**Deviations:** none. **Next pending phase:** `97_kb_tree_catalog`.
This commit is contained in:
2026-09-11 13:16:20 -04:00
parent bcaef800c5
commit a49be80b8e
42 changed files with 2893 additions and 143 deletions
+221 -7
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import AsyncGenerator
from types import SimpleNamespace
from typing import Any, cast
@@ -346,14 +347,27 @@ class _FakeCompletion:
"""One fake non-streaming ChatCompletion (``choices[].message`` shape).
``content=None`` mirrors the real wire where the field can be absent or
empty (reasoning-only replies, provider quirks).
empty (reasoning-only replies, provider quirks). ``finish_reason``
(phase 96) defaults to ``None`` — the provider omitting it — and the
incident signature is ``"length"`` (the whole ``max_tokens`` budget
spent in ``reasoning_content``).
"""
def __init__(self, content: str | None, empty_choices: bool = False) -> None:
def __init__(
self,
content: str | None,
empty_choices: bool = False,
finish_reason: str | None = None,
) -> None:
if empty_choices:
self.choices = []
else:
self.choices = [SimpleNamespace(message=SimpleNamespace(content=content))]
self.choices = [
SimpleNamespace(
message=SimpleNamespace(content=content),
finish_reason=finish_reason,
)
]
class _FakeCompletions:
@@ -362,12 +376,25 @@ class _FakeCompletions:
chunks: list | None = None,
fail: Exception | None = None,
completion: _FakeCompletion | None = None,
completion_seq: list[_FakeCompletion] | None = None,
) -> None:
self.chunks = chunks or []
self.fail = fail
self.completion = completion
#: Phase 96: a scripted per-``create()`` reply sequence (the retry
#: matrix) — popped one per non-streaming call, in order.
self.completion_seq = (
list(completion_seq) if completion_seq is not None else None
)
self.kwargs: dict | None = None
self.chat_kwargs: dict | None = None
#: Every non-streaming ``create()`` call's kwargs (the attempt
#: counter for the phase-96 retry matrix).
self.chat_calls: list[dict] = []
#: Every ``create()`` call (streaming + non-streaming), incl.
#: calls that raised (``fail``) — the attempt counter when the
#: failure happens inside the SDK call itself.
self.create_calls: int = 0
#: Every SDK-shaped stream handed out — teardown tests assert the
#: phase-48 ``close()`` on them (phase 71 task 02: with/without
#: a filter, the teardown path is the same object).
@@ -375,6 +402,7 @@ class _FakeCompletions:
async def create(self, **kwargs) -> _FakeChatStream | _FakeCompletion:
self.kwargs = kwargs
self.create_calls += 1
if self.fail is not None:
raise self.fail
if kwargs.get("stream"):
@@ -382,6 +410,11 @@ class _FakeCompletions:
self.streams.append(stream)
return stream
self.chat_kwargs = kwargs
self.chat_calls.append(dict(kwargs))
if self.completion_seq is not None:
if not self.completion_seq:
raise AssertionError("completion script exhausted")
return self.completion_seq.pop(0)
assert self.completion is not None
return self.completion
@@ -904,9 +937,12 @@ def test_chat_stream_abandon_with_filter_closes_stream() -> None:
def _make_chat_client(
completion: _FakeCompletion | None = None,
fail: Exception | None = None,
completion_seq: list[_FakeCompletion] | None = None,
**settings_kwargs: Any,
) -> tuple[LLMClient, _FakeCompletions]:
completions = _FakeCompletions(fail=fail, completion=completion)
completions = _FakeCompletions(
fail=fail, completion=completion, completion_seq=completion_seq
)
fake_openai = SimpleNamespace(chat=SimpleNamespace(completions=completions))
llm = LLMClient(_settings(**settings_kwargs))
llm._client = fake_openai # pyright: ignore[reportAttributeAccessIssue]
@@ -984,18 +1020,196 @@ def test_chat_empty_choices_raises_llm_error() -> None:
def test_chat_missing_content_raises_llm_error() -> None:
"""A silent empty summary must never be stored — None content fails."""
llm, _ = _make_chat_client(_FakeCompletion(None))
"""A silent empty summary must never be stored — None content fails.
Phase 96: pinned with the kill switch (``llm_retries=0``) so the
pre-phase-96 single-attempt behavior and message are asserted
verbatim (the retry matrix below pins the retried contract)."""
llm, _ = _make_chat_client(_FakeCompletion(None), llm_retries=0)
with pytest.raises(LLMError, match="empty content"):
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
def test_chat_whitespace_only_content_raises_llm_error() -> None:
llm, _ = _make_chat_client(_FakeCompletion(" \n\t "))
"""Whitespace-only content is empty (phase 96 kill-switch pin)."""
llm, _ = _make_chat_client(_FakeCompletion(" \n\t "), llm_retries=0)
with pytest.raises(LLMError, match="empty content"):
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
# ---------- one-shot empty-reply retry (phase 96, task 01) ----------
_DEFAULT_BASE = "https://aipi.reeseapps.com/v1"
def _empty(finish_reason: str | None = "length") -> _FakeCompletion:
"""An incident-shaped empty reply (``content=None``; ``finish_reason``
defaults to ``"length"`` — the 2026-09-11 signature)."""
return _FakeCompletion(None, finish_reason=finish_reason)
def test_chat_empty_then_success_retries_and_recovers(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""First reply empty (the incident shape), second reply has content →
exactly 2 attempts, ONE flat sleep of ``llm_retry_delay`` (default
5.0), the trimmed second reply is returned, and ONE warning fired
naming the model, the empty reply's ``finish_reason``, and the
attempt count."""
sleeps = _record_sleeps(monkeypatch)
caplog.set_level(logging.WARNING, logger="app.llm")
llm, completions = _make_chat_client(
completion_seq=[
_FakeCompletion(None, finish_reason="length"),
_FakeCompletion(" Recovered.\n"),
]
)
out = asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
assert out == "Recovered."
assert completions.create_calls == 2
assert len(completions.chat_calls) == 2
# Both attempts are byte-identical (same request).
assert completions.chat_calls[0] == completions.chat_calls[1]
assert sleeps == [5.0] # one flat BOR_LLM_RETRY_DELAY (default)
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
line = warnings[0].getMessage()
assert "lite" in line # the summary model (default)
assert "finish_reason=length" in line # the incident signature
assert "attempt 1/4" in line # failed attempt 1 of 1 + 3 retries
def test_chat_explicit_model_named_in_the_retry_warning(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The warning names the model actually requested (an explicit
*model* overrides the default)."""
_record_sleeps(monkeypatch)
caplog.set_level(logging.WARNING, logger="app.llm")
llm, _ = _make_chat_client(
completion_seq=[_empty(), _FakeCompletion("ok")],
llm_summary_model="tiny",
)
out = asyncio.run(llm.chat([{"role": "user", "content": "q"}], model="special"))
assert out == "ok"
line = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING][0]
assert "model=special" in line
assert "tiny" not in line
def test_chat_all_empty_exhausts_after_1_plus_retries_attempts(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Default ``llm_retries=3`` → exactly 4 attempts, 3 sleeps, then
``LLMError`` naming the attempts. One empty reply omits
``finish_reason`` (provider quirk) — the log line still formats
(``finish_reason=None``) and never crashes the diagnostic path."""
sleeps = _record_sleeps(monkeypatch)
caplog.set_level(logging.WARNING, logger="app.llm")
llm, completions = _make_chat_client(
completion_seq=[_empty(), _empty(), _empty(None), _empty()]
)
with pytest.raises(LLMError) as exc:
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
assert str(exc.value) == (
f"chat completion from {_DEFAULT_BASE} returned empty content on all "
"4 attempts — refusing to store a silent summary"
)
assert completions.create_calls == 4
assert sleeps == [5.0, 5.0, 5.0] # no sleep after the last attempt
lines = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
assert "attempt 1/4" in lines[0]
assert "attempt 2/4" in lines[1]
assert "attempt 3/4" in lines[2]
assert "finish_reason=None" in lines[2] # the omitted-finish_reason reply
def test_chat_all_empty_custom_retry_count_names_the_attempts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``llm_retries=1`` → exactly 2 attempts, 1 sleep, the exhaustion
message names 2."""
sleeps = _record_sleeps(monkeypatch)
llm, completions = _make_chat_client(
completion_seq=[_empty(), _empty()], llm_retries=1, llm_retry_delay=0.5
)
with pytest.raises(LLMError, match="all 2 attempts"):
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
assert completions.create_calls == 2
assert sleeps == [0.5]
def test_chat_first_success_never_retries(monkeypatch: pytest.MonkeyPatch) -> None:
"""Happy path untouched: exactly 1 ``create()`` call, ZERO sleeps,
the trimmed content is returned byte-identically."""
sleeps = _record_sleeps(monkeypatch)
llm, completions = _make_chat_client(_FakeCompletion(" Summary text.\n"))
out = asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
assert out == "Summary text."
assert completions.create_calls == 1
assert sleeps == []
def test_chat_no_choices_reply_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None:
"""D2: a choiceless reply raises immediately — 1 attempt, no sleep,
no retry (only empty content is the retryable class)."""
sleeps = _record_sleeps(monkeypatch)
llm, completions = _make_chat_client(
_FakeCompletion(None, empty_choices=True), llm_retries=3
)
with pytest.raises(LLMError, match="no choices"):
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
assert completions.create_calls == 1
assert sleeps == []
def test_chat_transport_failure_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None:
"""D2: a transport failure raises immediately — 1 attempt, no sleep,
no app-level retry (the openai SDK's own ``max_retries=2`` covers
wire-level failures)."""
sleeps = _record_sleeps(monkeypatch)
llm, completions = _make_chat_client(fail=RuntimeError("HTTP 502 Bad Gateway"))
with pytest.raises(LLMError, match="HTTP 502"):
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
assert completions.create_calls == 1
assert completions.chat_calls == [] # the SDK call itself raised
assert sleeps == []
def test_chat_zero_retries_raises_legacy_message_byte_identical(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The kill switch (``llm_retries=0``): one attempt, zero sleeps, the
PRE-phase-96 message byte-identically (asserted as the exact
string, not a pattern)."""
sleeps = _record_sleeps(monkeypatch)
llm, completions = _make_chat_client(_FakeCompletion(None), llm_retries=0)
with pytest.raises(LLMError) as exc:
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
assert str(exc.value) == (
f"chat completion from {_DEFAULT_BASE} returned empty content — "
"refusing to store a silent summary"
)
assert completions.create_calls == 1
assert sleeps == []
def test_chat_retry_delay_is_flat_never_backoff(monkeypatch: pytest.MonkeyPatch) -> None:
"""The recorded sleeps are the flat ``llm_retry_delay`` each time —
never a growing backoff (the phase-67 convention)."""
sleeps = _record_sleeps(monkeypatch)
llm, _ = _make_chat_client(
completion_seq=[_empty(), _empty(), _empty(), _empty()],
llm_retries=3,
llm_retry_delay=1.25,
)
with pytest.raises(LLMError, match="all 4 attempts"):
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
assert sleeps == [1.25, 1.25, 1.25]
# ---------- chat_stream_retried (phase 67, task 01) ----------
_RETRY_MSGS: list[dict[str, str]] = [{"role": "user", "content": "q"}]