Files
brain-of-reese/.agent/user_stories/sync-model-fail-fast.md
T
ducoterra 02c76ad328 chore(agent): phase roadmap from TODO.md — 8 phases (40–47), 24 tasks
Converts the 9 TODO items into an executable phase roadmap (Protocol B,
appended after phase 39):

- 40 tuning toggle anonymous flash (TODO L3)
- 41 sync fail-fast + modal when a model is down (TODO L4)
- 42 no reply autoscroll (TODO L5)
- 43 thinking scroll back — user scroll + gated autoscroll (TODO L7)
- 44 markdown tables (TODO L6)
- 45 agent unlimited tool calls behind BOR_AGENT_MAX_ROUNDS (TODO L8)
- 46 mobile hamburger nav (TODO L9)
- 47 quadlet + jinja import formats, A9 revision (TODO L10–L11)

Each phase carries a user story, a dedicated Playwright E2E suite plan,
and owner-locked decisions (R1 A9 format extension, R2 phase-37 budget
revision, A1–A5 scope decisions) confirmed 2026-08-27.

Also records the completed phases 30–39 todo/ -> complete/ moves that
were pending in the working tree. TODO.md is cleared (items now live in
.agent/phases/todo/).
2026-08-27 18:25:53 -04:00

5.2 KiB

Story: Sync fails fast + modal when a model is down

Phase: 41_sync_fail_fast_models · Source: TODO.md L4 · E2E: tests/e2e/test_sync_model_down.py

Bug report (verbatim, TODO.md L4)

"If the embedding or lite model is not accessible the sync button should fail fast and there should be a modal error popup explaining that the model isn't available."

Narrative

As the admin, when I press "Sync sources" with the aipi models (embed or lite) unreachable, I don't want to wait through git clones and a partial import to discover the KB can't be updated — and a tooltip on a button is not a readable error. The sync should fail fast (before any expensive work) with a clear "the model isn't available" message, shown in a modal dialog I can read and dismiss.

  • Given the embed or lite model endpoint is unreachable
  • When I press "Sync sources"
  • Then the run fails within a couple of seconds (before any git clone), the button settles retry-ready, and a modal dialog explains which model isn't available.

Acceptance criteria

  1. Server fail-fast: POST /api/sync with a dead LLM endpoint reaches state: "failed" with a message naming the unavailable model (embedding first, then summary/lite) without cloning any source — the probe (one small embedding + one tiny completion against BOR_LLM_SUMMARY_MODEL) runs before source resolution and before any clone_or_pull.
  2. Modal: on a failed sync the page shows a modal error dialog (role="alertdialog", aria-modal="true") with a title, the sanitized error text (rendered via textContent — XSS-safe), and a close control; it closes on the close button, Esc, or backdrop click; focus moves into the dialog on open and returns to #sync-btn on close.
  3. Every page: the modal is built by the shared header module (frontend/assets/header.js), which owns the sync state machine — so it appears wherever #sync-btn exists (all six pages from phase 34).
  4. Existing surfaces kept: the button's failed-state title / aria-label / .is-error affordance and the Sources page's #sync-error-banner (via bor:sync-status) are unchanged — the modal is the primary, readable surface.
  5. Success path unchanged: a healthy model still runs clone → import → overview exactly as phase 32/35/38 define it (regression).

Owner-confirmed (2026-08-27, roadmap A4)

  1. The probe runs before git clones — the fastest possible failure; it costs one small embedding request and one ~1-token completion.
  2. The modal is the primary failure surface on every page; the button-title affordance and the Sources banner stay as secondary surfaces.

UI Visualization & Structure

  • Server (app/rag/llm.py, app/api/sync.py): a new ModelUnavailableError (subclass of LLMError) + an async check_models(llm) probe: embed_one("sync model check") then a tiny chat([...]) against the summary model; each failure mode maps to a message naming the model and that it isn't available (the sync sanitizer's credential masking still applies downstream). _run_sync calls it first, after LLMClient() construction — before effective_sources, before any clone.
  • UI (frontend/assets/header.js, frontend/assets/styles.css): applySyncFailure(status) additionally opens showSyncModal(status): a lazily-created backdrop + role="alertdialog" panel appended to <body> (so no page markup changes), error text via textContent, close button + Esc + backdrop-click dismissal, focus management as in AC 2. Styled with the existing dark-theme error palette (PLAN §7.2: #fca5a5 on #2d1318 class, error border), :focus-visible per the global rule, no motion under prefers-reduced-motion.
  • Non-goals: no new endpoint, no retry-from-modal button (the button itself is retry-ready), no change to the 2 s poll lifecycle.

Playwright Mapping Rule

Test Scenario → tests/e2e/test_sync_model_down.py (mock LLM; DB up). The suite boots its own module-scoped app on a distinct port (conftest pattern used by test_sync_button.py) with BOR_LLM_BASE_URL=http://127.0.0.1:9/v1 (dead port — connection refused) and a local file:// fixture repo as the configured source, so a (regressed, non-fail-fast) run would spend time cloning before failing:

  1. test_model_down_fails_fast_with_modal — admin login, click #sync-btn; within a short wall-clock budget (≤ ~10 s, vs the 60 s generous budget of the healthy-run suite) the button settles retry-ready and the modal is visible with an error naming the model; assert the dialog role/aria contract.
  2. test_modal_dismissal — close via button, Esc, and backdrop click (one fresh failure per path); focus returns to #sync-btn each time.
  3. test_sync_error_surfaces_unaffected (phase 32 regression) — after the failure the button keeps its title / .is-error affordance; on /sources.html the #sync-error-banner still renders off bor:sync-status.
  4. test_healthy_sync_still_succeeds (phase 32/35 regression) — the session mock-backed app (or a second healthy module app) still runs the full clone → import → overview pipeline to "Synced HH:MM".