phase: 96_oneshot_resilience
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:
@@ -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"}]
|
||||
|
||||
Reference in New Issue
Block a user