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`.
5.6 KiB
5.6 KiB
Task 01 — chat() retries an empty one-shot reply under the house retry policy
Phase: 96_oneshot_resilience · Story: n/a (incident-driven resilience — phase 67's owner-locked A1 left the one-shot path out of scope; this task closes that gap per owner request 2026-09-11).
Objective
LLMClient.chat() (the one-shot surface used by document summaries, the KB overview, folder summaries, and the sync probe) retries an empty-content reply up to BOR_LLM_RETRIES times with BOR_LLM_RETRY_DELAY between attempts, logs each retry, and only then raises — so a transient "the model answered but said nothing" reply (the 2026-09-11 incident: content="", finish_reason="length", the whole budget spent in reasoning_content) no longer loses a summary on its first attempt.
Work
app/rag/llm.py—LLMClient:- Extract
chat()'s single-attempt body verbatim into a private_chat_once(self, messages, model) -> str: the existingtry/excepttransport wrap (→LLMErrorwith the sanitized base URL), the choiceless check (…returned no choices), and the empty-content check (contentNone ornot content.strip())._chat_oncemust also surface the empty reply'sfinish_reasonto its caller for the log line — e.g. raise a small internal signal or return it alongside; keep the public error messages exactly as they are today for the no-retry cases. - Rewrite
chat()around it:N = 1 + self.settings.llm_retriestotal attempts. Attempt 1 → on the empty-content failure only: while attempts remain,logger.warning(…)with the model name, the empty reply'sfinish_reason, andattempt {n} of {N}(PLAN §9 ample logging — this line is the greppable record of the incident class), thenawait asyncio.sleep(self.settings.llm_retry_delay)(flat delay — the phase-67 convention), then the next attempt. Every otherLLMError(transport, no-choices) propagates immediately — no app-level retry (the openai SDK's ownmax_retries=2already re-POSTs wire-level failures; an app-level transport retry would stack on top of it). - Exhaustion: raise
LLMErrorwith the updated messagef"…returned empty content on all {N} attempts — refusing to store a silent summary"(same base-URL sanitization as today). Whenllm_retries == 0, raise the current message verbatim (…returned empty content — refusing to store a silent summary) — the kill-switch must be byte-identical to pre-phase-96 behavior (house byte-identical convention). - Update
chat()'s docstring: the phase-30 contract ("a silent empty summary must never be stored") stands; add the retry policy (D1/D2/D3 of00_phase.md): which failures retry (empty content only), the knobs, the flat delay, the exhaustion message. check_models(the "ping" probe) needs no change — it benefits automatically.
- Extract
.env.example— theBOR_LLM_RETRIES/BOR_LLM_RETRY_DELAYcomments gain that they now cover "the chat-turn stream (phase 67) and one-shot summary calls (phase 96)". No new settings, noapp/config.pychange (the validators for both knobs already exist from phase 67).
- ASSUMPTION: the retry loop lives inside
chat()itself (one place, every consumer protected) — NOT in each caller; the generators' per-folder fail-soft semantics are untouched (they still catch the post-exhaustionLLMErrorexactly as today). - ASSUMPTION:
finish_reasonavailability — the empty reply is already parsed (resp.choices[0]); reading.finish_reasonoff it is free. If a provider omits it, logfinish_reason=None(the line must never crash on the diagnostic path).
Testing & Quality
- Unit —
tests/unit/test_llm_client.py(extend the existing_FakeCompletion/_make_chat_clientharness; the fake completions object can be scripted to yield a sequence of replies percreate()call):- empty-then-success: 2 attempts, exactly 1 sleep of
llm_retry_delay, returns the second reply's trimmed content; theWARNINGfired once (caplog). - all-empty with
llm_retries=3(default): 4 attempts, 3 sleeps,LLMErrormatchingall 4 attempts; with a customllm_retries=1: 2 attempts, message names 2. - first-attempt success: exactly 1
create()call, zero sleeps, return value byte-identical to today's behavior (the happy path is untouched). - choiceless reply: 1 attempt, no retry, the existing
no choiceserror. - transport failure: 1 attempt, no retry, the existing wrapped error.
llm_retries=0: empty reply → 1 attempt, zero sleeps, the legacy message byte-identical (assert the exact string).- sleep value: record
asyncio.sleepcalls (monkeypatch) — flatllm_retry_delayeach time, never a growing backoff. - update the two existing pins (
test_chat_missing_content_raises_llm_error,test_chat_whitespace_only_content_raises_llm_error, ~L989–1000) to the new contract — either pin the exhaustion path with a low-retry setting or setllm_retries=0there to keep the legacy-message assertion; bothNoneand whitespace-only content must still refuse.
- empty-then-success: 2 attempts, exactly 1 sleep of
- Coverage: >90% on the modified
app/rag/llm.pylines (uv run pytest --cov=app --cov-report=term-missing). - No integration/E2E in this task (tasks 03/04 cover the paths end to end).
Completion Criteria
uv run pytest tests/unit/test_llm_client.py -vgreen with the new pins above- full suite green, coverage >90%,
uv run ruff check . && uv run pyrightclean - the streaming path (
chat_stream,chat_stream_retried) is byte-identical — no diff outsidechat()/_chat_onceinapp/rag/llm.py .env.examplecomments updated; no new settings- no behavior change in completed work (phase 67's retry suite green)