## Final report — phase 186 `git_log_streaming_dates`
**State found:** all three task files were already implemented and in `.agents/phases/complete/186_git_log_streaming_dates/`, so this run was the final verification + documentation pass.
### Verification (all green)
| Gate | Result |
|---|---|
| `pytest --cov=app --cov-report=term-missing` | **4531 passed, exit 0** · coverage `TOTAL 6872 stmts → 99%` (gate >90%) |
| `tests/e2e/test_git_log_stream_dates.py -v --no-cov` (isolation) | **5 passed** |
| `ruff check .` | All checks passed |
| `pyright` | 0 errors |
Implementation shape confirmed against the phase contracts: `_parse_commit_dates(str | Iterable[str])` (existing string pins untouched — only 3 non-test lines removed from `test_git_sync.py`), `stream_git_lines` (`Popen` + daemon stderr drain + per-line stdout + terminate-on-abandon, `run_git`-shaped `GitSyncError`), `file_commit_dates` on the stream with byte-identical dates and the fail-soft funnel unchanged, `async_sync` propagating the `to_thread` errors to the existing `except`. A11 holds: two `subprocess.` sites, both in `scripts/git_sync.py`, both off-loop.
### Documentation-consistency pass
Verified (clean): all ~100 `BOR_*` README defaults against a bare `Settings()` (the local `.env` produced false positives — none real), `.env.example` completeness, every module/route/page/test path referenced, `compose.yaml`/`Containerfile`/`entrypoint.sh` claims, E2E port conventions (8901 / 8123), palette count (17), chip cap (90), token format, payload limits, retrieval battery + audit commands.
Fixed (both files, prose only):
- **Page-naming drift** — README/AGENTS called the sync/catalog page "the Sources page" in 5 places, contradicting the README's own convention (and `frontend/index.html`): the **Sync sources** button and the document catalogue are the **RAG** page (`/sources.html`); "Sources" is `/git-sources.html`. Now `RAG page (/sources.html)` in README:186, 376, 418, 682, 713, 745, 1006, 1057 and AGENTS:19, 38, 183.
- **Phase-186 AST-pin wording** — "fails if a third spawn appears anywhere in `app/` or `scripts/`" overstated it (the pin *enumerates* the spawns, including the non-git Valkey probe in `app/core/caching.py`); rewritten to describe the enumeration.
- Moved `00_phase.md` into `complete/186_git_log_streaming_dates/` (AGENTS rule 2 moves the whole directory); `todo/` now starts at 187.
⚠️ **One mistake to disclose:** while tidying line wraps I ran `git checkout README.md`, which discarded task 03's uncommitted phase-186 README bullet. I restored it verbatim from the full `git diff` captured earlier in this session (plus the two corrections above) and re-verified the section renders as intended — no other README content was uncommitted, and AGENTS.md was untouched by the checkout. A blanket line-length rewrap also reflowed 4 unrelated lines; all four were restored to their exact original text (`git diff` now shows only intended changes).
Nothing was committed or staged — the working tree is left for the harness: modified `scripts/git_sync.py`, `app/api/sync.py`, `app/rag/git_sources.py`, `tests/unit/test_git_sync.py`, `tests/integration/test_git_file_dates.py`, `README.md`, `AGENTS.md`; new `tests/e2e/test_git_log_stream_dates.py`, `.agents/phases/complete/186_git_log_streaming_dates/`, `.agents/reports/186_git_log_streaming_dates/`.
14 KiB
14 KiB
AGENTS.md — Operating Rules for All Agents on This Repo
- Always read
.agents/PLAN.mdfirst. It contains the architecture, the LOCKED DECISIONS, the UI/UX standards, the data model, and the roadmap. Do not design anything that contradicts it. - Follow the phased execution protocol in
.agents/phases/. Work through.agents/phases/todo/in numeric order. When a phase is complete, move its directory to.agents/phases/complete/. - Strictly adhere to the LOCKED DECISIONS (PLAN §2). If you believe a LOCKED decision is wrong, stop and flag it — do not silently deviate.
- "One Story, One Phase": each user story in
.agents/user_stories/corresponds to a distinct execution phase, and every phase — story-driven or owner-directed — includes its own dedicated Playwright E2E test suite (one test file for the phase, run in isolation). - "UI Structure Check": before finalizing any UI component, verify that
it follows the layout principles in
.agents/PLAN.md§7 (proper container usage, the 72rem centered reading column — every page rides the 72rem container at every viewport; phase 100 supersedes §7.1's 46rem chat column, full-width tables on the catalogue pages — no skinny wasted-space lists; the catalogue tables (RAG, Sources, History) are bounded scroll panels —max-height: min(70vh, 64rem)— with a sticky header row; a clipped action label shows an ellipsis hint (.cell-clip) with the full text kept reachable via the control's aria-label (phase 156); an in-flight turn never paints an empty answer bubble — the box is created empty as the reasoning anchor and.msg .bubble:empty { display: none }hides it until the first answer token (phase 166); an answer's inline[text](url),and####+ heading render through the ONE shared renderer into an allowlist-gateda.md-link(underlined, never ajavascript:/data:element), a container-cappedimg.md-img(max-width: 100%— no horizontal overflow at 375px) and anh5/h6sized into the existing type scale — never printed reference text (phase 180); an SVG document paints as a graphic — the viewer's image block adds.doc-image-img--vector(width: 100%,object-fit: contain) whenformatissvgso aviewBox-only drawing spans the.doc-imagecard instead of the 300×150 default box, while the chat figure and RAG-table thumbnail keep their fixed caps and the bytes stay an<img>src (never inlined) (phase 181); the document viewer's page strip is CLASS-scoped — the primary class shows the strip in place of the text, the additive class never hides the text: Text/Pages toggle group (aria-pressed, Text default) with the strip behind it (phase 163); a lazily-drilled KB-tree level keeps the level the user is ON rendered while its node fetch runs, and its ONE in-flight row (role="status") / alert + Retry row (role="alert", a real<button>) sits inside that same bounded scroll panel — and the stat cards read the server'stotals, never a walk of a depth-limited tree (phase 167); an answer's code block is EITHER verbatim-and-badged OR plainly the model's — never a silently-substituted hybrid: the```borlocator fence renders through the ONE shared renderer as.verbatim[data-bor-locator]with the model's body DROPPED (renderMarkdown(md, { verbatim: true }), off by default so every other surface is byte-identical), styled from palette variables only with amin(40rem, 70vh)scroll body, a stickysource/pathcaption and auser-select: noneline-number gutter, while the plain.code-blockwrapper carries a Copy button and no badge (phase 182); those placeholders hydrate against the frame'sanswer_textthrough the ONE panel client (frontend/assets/verbatim.js— a classic script loaded AFTERmarkdown.json the chat AND shared pages, no second locator grammar, a plain string compare against the server's canonicallocator, the caption's numbers always the SERVER's extent, an unresolvable locator painting the pinned sentenceI could not verify this against the knowledge basewith no badge, no body and none of the model's bytes, no panel left at "Retrieving the exact text…" after a terminal frame (theerrorframe, the 300 s guard, Stop and the failure funnel all settle it), the Copy button's clipboard content equal to the server string exactly, and the shared page's panel Copy button + plain/document.htmllink the ONE owner-directed exception to that page's zero-controls rule) — phase 182 task 07) and meets WCAG 2.1 AA basics (semantic landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the stream). - "No CDN Rule": all CSS, JS, fonts, and images must be served statically
from within the FastAPI application. No
<script src="https://…">or<link href="https://…">tags in HTML templates unless bundled locally. An integration test enforces this on the index page. - "Debugpy Check": any new entrypoint must keep the conditional import —
debugpyis imported only whenDEBUGPY=1(seeapp/core/debugging.py); default off, never imported otherwise. - Git protocol: Git is mandatory. One atomic, professional commit per
completed phase (Conventional Commits, e.g.
feat(rag): stream RAG answers over SSE with source citations). Always append--no-gpg-sign(the repo also setscommit.gpgsign=false)..agents/is tracked and committed with the phase — only runtime artifacts (.agents/phase-sessions/,.agents/pipeline.log) are gitignored. - Test gates are non-negotiable: a phase is complete only when unit +
integration tests pass,
app/coverage is >90% (uv run pytest --cov=app --cov-report=term-missing), and the phase's Playwright E2E file passes in isolation (uv run pytest tests/e2e/test_<story>.py -v --no-cov). - Amply log, visibly feed back: server-side, log the required
per-turn line (PLAN §9 — phase 182 appends
verbatim_blocks=Nafterscaffold_stripped=N: the verbatim display blocks actually SENT on thedoneframe,0on every turn without them, deflected included) and the per-response byte-budget line (app/core/response_budget.py— oneapi method=… path=… status=… bytes=… enc=… duration_ms=…line per/api/*response, INFO at/overBOR_LOG_LARGE_RESPONSE_BYTES, read it withgrep ' :: api ', audit it withuv run python -m scripts.api_payload_audit, phase 167); UI-side, honor the "never stale" feedback contract (PLAN §7.4). - Never block the event loop: anything blocking (a
subprocess, synchronous file/network I/O, a heavy walk) reached from anasynchandler or a spawned background coroutine goes throughasyncio.to_thread(orcreate_subprocess_exec). The sync's git calls do (phase 179: a longgit cloneused to park the single uvicorn loop and freeze the whole HTTP surface, the 2 sGET /api/sync/statuspoll included — pinned bytests/integration/test_sync_git_freeze.pywith a 3 s stub and bytests/e2e/test_sync_git_freeze.pywith a real ~9 sfile://clone). A cooperative cancel unblocks the run at theto_threadawait; the worker thread itself runs to completion (threads are not interruptible) while the loop keeps serving — pinned by the same E2E module's cancel-during-clone test. Since phase 186scripts/git_sync.pyhas two git spawn sites — the bufferedrun_gitand the streamingstream_git_lines(the commit-date walk yieldsgit logline by line instead of holding the whole history in RAM) — and both are off-loop viaasyncio.to_thread; thesubprocess.single-site intent survives as "one per shape, both owned there", enforced bytests/unit/test_git_sync.py::test_only_two_git_spawn_sites, and the owner-visible date contract through the stream (last-commit dates, the mtime fallback, the D1 manual lock) bytests/e2e/test_git_log_stream_dates.py. - Never hydrate what you delete:
Document.chunksispassive_deletes=True(phase 183), so deleting a document removes its chunk rows in the database (chunks_document_id_fkeyisON DELETE CASCADE, alembic 0001) and a mass prune costs O(documents). The importer's prune and the source-removal endpoint both rely on it. Do not remove the keyword, and do not add a delete path that SELECTschunks(or aChunk.embedding) on its way to deleting them — one hydrated 768-dim embedding is ~24.8 KiB of Python, which is how the 2026-10-15 sync's 3,008-document prune reached 4 GB and locked up the VM. Pinned bytests/integration/test_prune_cascade_statements.py(statement count, zero orphan chunk rows, and a live-FK drift guard that fails the suite if the cascade clause ever disappears — because thenpassive_deletes=Truewould silently orphan rows). Thedoc.chunks = [...]re-embed path is the deliberate, documented exception (collection replacement legitimately needs the loaded collection; changed files only). - Release the sync session at the proven-safe points only: an import
run holds one session and
expire_on_commit=False(app/db.py) means a commit releases nothing, so_unchanged_doc'sfile_hashstamp keeps every walkedDocument— content included — resident in the session's own strongdirtyset (not collectable): memory grew with the KB. The cadence is thereforesession.expunge_all()everyBOR_SYNC_SESSION_RESET_FILESfiles at the TOP of the per-file loop and once per source before the two post-passes (phase 184;0= never, the pre-phase rollback).expunge_allDISCARDS unflushed changes, so never move it into_index_file,_unchanged_doc,_store_summaryor a post-pass — their locals are live across statements — and never add a reset before a file's per-file commit. Pinned bytests/integration/test_import_session_retention.py(bounded with the cadence on, unbounded with it0, byte-identical summaries and DB) and through the UI bytests/e2e/test_sync_session_reset.py(two module apps, one per cadence: every document exactly once, the all-unchangedre-sync, and the0-cadence run byte-identical to the3-cadence one). - The prune reads six columns and commits in batches:
_pruneselects its candidates throughload_only(id, source, path, is_image, image_path, page_count)order_by(Document.id)— the exact set the phase-122 images-off guard, the phase-126 parent-PDF rule and the image/page copy cleanup read, and NOTcontent(252 MB of markdown per full KB walk) — and commits everyBOR_SYNC_PRUNE_BATCHdeletes (0= the pre-phase single commit; phase 184). Never read anotherDocumentattribute inside that loop (a deferred column costs one lazy SELECT per document and unbounds the walk — noraiseloadpaper-over, add the column instead), and never swap the per-documentsession.delete(doc)for a bulkdelete()(the guards,changed_pathsand the log line depend on the per-document shape). A non-zero batch deliberately trades the all-or-nothing prune for a prefix (LOCKED A2):seenis recomputed from disk, so the next sync converges. Pinned bytests/integration/test_import_session_retention.py(the capturedSELECT's column list, exactly one document-wideSELECTper prune (a second one is a per-row lazy load), the commit counts at10and0, both guards on light rows) andtests/e2e/test_sync_session_reset.py(the five-document prune at batch2through the RAG page (/sources.html): both folders cleared, the survivors' chunks intact, zero orphan chunk rows). - The unchanged fast path reads metadata, never markdown: the four
row lookups that share it (
_index_file,_index_pdf_file,_index_one_embedded_image,_index_image_file) all go through the ONE statement_doc_row_stmt(app/rag/importer.py), which defersDocument.contentand NOTHING ELSE (phase 185, LOCKED A1) — the row load is the only place the fast path gets its row, and hydratingcontentfor every inspected file is what put ~252.6 MB of markdown in front of a run that re-indexed 217 of ~27k files. Do not add a fifth whole-entityselect(Document).where(Document.source == …, Document.path == …), do not swap thedeferfor aload_onlyallowlist (a future column would silently become lazy and resurface as an N+1), and do not addraiseload/noload(LOCKED A2):_unchanged_doc's phase-118 summary-NULL backfill genuinely readsdoc.contentand must pay one per-row, one-time SELECT instead of raising. Pinned bytests/integration/test_import_fast_path_columns.py(the captured fast-pathSELECTnames nodocuments.content, exactly oneSELECTper unchanged file with a summary, exactly one PK follow-up for the backfill),tests/unit/test_importer_defer_content.py(contentis the only deferred column, the four loads share the helper, noraiseload/noloadin the importer) andtests/e2e/test_fast_path_deferred_content.py(the owner-visible parity: the summary-NULL backfill survives the deferral with exactly oneis_summarychunk, an unchanged doc's viewer shows the full stored text, a changed file re-indexes and the fast path still fires after).
Quick reference
podman compose up -d db # start Postgres 17 + pgvector
cp .env.example .env # once; then edit
uv run alembic upgrade head # apply migrations
uv run uvicorn app.main:app --reload # dev server (add DEBUGPY=1 to debug)
uv run pytest # unit + integration
uv run pytest --cov=app --cov-report=term-missing
uv run playwright install chromium # once
uv run pytest tests/e2e/test_smoke.py -v --no-cov
uv run ruff check . && uv run pyright # lint + types
uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt # retrieval regression battery (phases 119/162)
uv run python -m scripts.api_payload_audit # endpoint byte audit vs the 256 KB budget (phase 167; app must be running)