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/).
This commit is contained in:
2026-08-27 18:25:53 -04:00
parent 492d8275e7
commit 02c76ad328
66 changed files with 1906 additions and 0 deletions
@@ -0,0 +1,97 @@
# 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".