phase: 194_sync_pending_count
Build and Push Containers / build-and-push-app (push) Successful in 2m5s
Build and Push Containers / build-and-push-db (push) Successful in 13s

**Phase 194 — final verification pass: all green.**

**Verified (all 5 tasks already complete; nothing re-implemented needed)**
- `app/rag/importer.py`: `_prescan_pending` + `_pending_hash_stmt` (scalar `documents.file_hash`, own session, ONE `asyncio.to_thread` span, REPLACES the stats pre-walk), `on_scan_progress`, `progress` pair = pending position, `0` branch = byte-identical phase-193 walk.
- `app/config.py`/`settings_catalog.py`/`.env.example`: `sync_pending_count=1` + validator; `app/api/sync.py`: `"scan"` phase, `scan_done`/`scan_total` (additive after `files_total`, kept at every terminal), forced/throttled RSS split; `frontend/assets/sources.js`: `fmtSyncPhaseLabel` scan branch first-arm, `fmtSyncLabel` untouched.

**Tests / lint / coverage (exact commands)**
- `uv run pytest --cov=app --cov-report=term-missing` → **4835 passed**, `app/` 99% (7240 stmts / 16 miss), exit 0
- `uv run pytest tests/e2e/test_sync_pending_count.py -v --no-cov` → **8 passed in 150.81s** (phase suite, isolated)
- 14 sync-lineage E2E suites, one pytest process each (`test_sync_remaining_count`, `…_chunk_progress`, `…_progress_box`, `…_peak_memory_visible`, `…_label_mobile`, `…_upload_progress`, `test_archive_upload_sources`, `…_image_progress`, `…_summary_visibility`, `…_status_freshness`, `test_smoke`, `test_fast_path_deferred_content`, `test_sync_session_reset`, `test_sync_git_freeze`) → **all exit 0**
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**

**Completion criteria** — all met: `N remaining` = predicted change set (E2E arms 1/2/5, first import counts the walk, no-change → no count part); `scan` phase visible + flips to `import`, scan pair reset/kept at terminals (`test_sync_scan_phase.py`); `BOR_SYNC_PENDING_COUNT=0` walk semantics ("7 remaining" arm) + **negative refuses startup naming the field (verified live by importing `app.main`)**; pre-scan off-loop + cancel lands `cancelled` (integration pins); CLI/upload byte-identical (unedited pins green); payload additive (`_PRE_187_STATUS_KEYS | _MEMORY_KEYS | _SCAN_KEYS`); suite green, coverage >90%, lint/types clean.

**Documentation** — 3 fixes: README sync "States" bullet had an **unclosed parenthesis** (pre-existing, 193/194) and the scan-label "total is 0" clause was ambiguous about a `=0` run (it still counts the walk once the import starts); **AGENTS.md rule 17 had a stray `)`** (pre-existing from phase 191). Also verified consistency both directions for all 87 settings ↔ README ↔ `.env.example` ↔ settings catalog, the pages table ↔ `app/main.py` routes, no stale `BOR_` names, and README/AGENTS claims through phase 194.

**Notes/deviations** — no live-infra changes (prod sync knob ships default-on, no deployment touched); no git index/history commands run (all changes left in the working tree for the harness); commit criterion left to the harness as instructed.

**Next pending phase:** none — `.agents/phases/todo/` holds only `194_sync_pending_count`, which is now ready to move to `complete/`.
This commit is contained in:
2026-10-20 06:36:21 -04:00
parent ae080ac7f3
commit a45f744604
55 changed files with 7610 additions and 179 deletions
+85 -6
View File
@@ -727,11 +727,29 @@ only) runs the whole source refresh in one click:
- States: while polling `GET /api/sync/status` every 2 s, the button
becomes the spinner (icon only — its label parts are hidden while a
run is in flight, and its accessible name is the full live label),
and the full live detail — "Syncing… <file> N remaining" during the
import phase (the live file, its `N remaining` file count — N =
and the full live detail — "Scanning… (n/total)" while the importer
pre-scans (phase 194: the scan's own walk position over `scan_done` /
`scan_total`, and NEVER a file — the scan names none, and this label says
Scanning where the sync count would otherwise wear the walk's shape; no
count part at all while the SCAN's own total is 0 — an empty knowledge
base, or a `BOR_SYNC_PENDING_COUNT=0` run, which reports no scan position
at all and so shows the bare `Scanning…` for the moment before the first
file (that rollback still counts the WALK once the import starts, below))
— then "Syncing… <file> N remaining"
during the import phase (the live file, its `N remaining` file count — N =
total − done, shown while N ≥ 1 and hidden at the final file, since
the owner reads the distance to the end better than the position in
the whole; the upload's `Importing` label shares the ONE builder and
the whole. Since phase 194 the pair counts the WORK, not the WALK:
before the walk the importer pre-scans the importable files and `total`
is the size of that **predicted change set** (`documents.file_hash`
missing or different — the unchanged fast path's own predicate), so a
~10,000-file sync that changes 10 files says "10 remaining" and a
no-change sync shows no count part at all; the position holds while the
walk moves over the unchanged files and a first import counts every
file, exactly as phase 193 did. `BOR_SYNC_PENDING_COUNT=0` restores the
walk position; the scan runs only for a caller that passes the progress
hook, so the CLI and the upload job pay nothing — the upload's
`Importing` label shares the ONE builder and
the same shape, though since phase 90 that run reports no counts, so
today it renders bare; and — while a file's embed batch is in flight
— the pinned `· chunk n/m` embed position, batch granularity: the
@@ -843,6 +861,64 @@ only) runs the whole source refresh in one click:
exactly one `is_summary` chunk, an unchanged doc's viewer still shows
the full stored text, and a rewritten file re-indexes to the new text
with the next sync all-`unchanged` again).
- **The running count counts the work, not the walk** (phase 194). The
owner's prod sync said "1216 remaining" on a ~10,000-file knowledge base
that had ~10 files to change (report of 2026-10-19): the phase-193 label
was fine, its inputs were the whole walk. The status's `phase` machine gained
a stage for the fix — `null` (model-check + clone/pull prelude) → **`"scan"`**
(set immediately before the import, where the prediction runs) → `"import"`
(flipped by the first file the walk reaches) → `"overview"` → `"summaries"`,
cleared by every terminal — and the scan reports its own position in the
ADDITIVE `scan_done` / `scan_total` keys (the file it has hashed over the
importable count: `0/0` before it starts, on a `BOR_SYNC_PENDING_COUNT=0` run
and on an empty knowledge base, and KEPT beside the final file counts in every
terminal so a reloaded page still shows the run's shape). `current_file` stays
`null` through the scan — predicting a change set names no file — which is why
the Sources page renders that stage as its own "Scanning… (n/total)" label
(the ONE `"scan"` branch of `fmtSyncPhaseLabel`, in the same count slot the
import's `N remaining` uses, so the untruncated label still rides the button
title and the `aria-live` box) and the scan
is sampled like every other stage: its set and its flip to `"import"` force a
memory read, its own per-file hook does not (`LOCKED A4`).
`import_sources` itself
**pre-scans** (`_prescan_pending`): every importable file gets one
streamed sha256 (`_file_hash`) and one SCALAR `SELECT documents.file_hash`
(`_pending_hash_stmt` — one column, no entity, nothing in the identity
map), and the file is pending when the stored hash is NULL or differs —
the same predicate the unchanged fast path applies, so a pre-phase NULL
row is predicted changed AND really re-indexed and stamped. `total` is the
size of that set and `done` its position, which holds while the walk moves
over the unchanged files. Costs the phase accepted (A3): one extra read +
hash + index probe per importable file per run (the second read of the same
file is page-cached), and the file is hashed TWICE on purpose — the scan's
digest is the prediction, the loop's is the truth, so a file edited in
between is still detected. The span is ONE `asyncio.to_thread` call (the
phase-179 rule: inline, 10,000 hashes + SELECTs would park the single
uvicorn loop and freeze the 2 s status poll), it reads through a session of
its own (a `Session` is not thread-safe and a cancel at the await lets the
worker run to completion after the run has moved on), and `summary.files`,
`seen`, the prune, the date refresh and every log line are untouched.
**Where the operator sets the ceiling:** `BOR_SYNC_PENDING_COUNT` (`1` the
pending count, the default; `0` the exact phase-193 walk position, the
no-redeploy rollback; negative refuses startup naming the field; read once
per run). Pinned by `tests/unit/test_sync_pending_count.py` (the gate — no
`progress` caller runs no scan, the `0` branch runs the old stats pre-walk,
the read-once + `to_thread` markers, the one-column projection, the four
predicate states, the `done`-as-pending-position) and
`tests/integration/test_sync_pending_count.py` (the parity — the predicted
set's size equals the terminal's `added + updated` on a stable KB across the
fresh / unchanged / incremental arms, zero entity hydration, the loop stays
responsive through the span, and a cancel at the await lands while the
worker finishes on its own), and in the browser by
`tests/e2e/test_sync_pending_count.py` — the real Sources page against two
live apps over one mock model with a per-request delay: the first sync
counting the phase-193 arithmetic down to the final file, the
owner's case (8 files indexed, 2 changed) holding at "2 remaining" while
the walk names the unchanged ones and hiding it on the final predicted
file, the `BOR_SYNC_PENDING_COUNT=0` app answering "7 remaining" for that
same fixture, the no-change and empty-source edges, the scan's own position
climbing through a 1,000-file pre-scan that the running server keeps
answering, and the upload status still carrying no scan fields at all.
- **The commit-date walk streams** (phase 186). A git source's per-file
last-commit dates come from one `git log --name-only --format=@@%cI`
over the checkout — a walk that scales with the repository's **entire
@@ -1781,9 +1857,11 @@ grep ' :: ' server.log | grep 'sync: failed state=' # the failed ones, with t
very same numbers (one reading, two surfaces — the page and the log
can never disagree).
- **Sampled, never per-file** (LOCKED A4): the run reads its own memory
at the run start, at each phase transition (`import` → `overview` →
`summaries`), in every terminal, and — throttled to at most one read
per 5 s — from the import's per-file progress hook, so the number
at the run start, at each phase transition (`scan` → `import` →
`overview` → `summaries` — phase 194 added the first stage and its flip),
in every terminal, and — throttled to at most one read
per 5 s — from the import's per-file progress hook (and, since phase 194,
the pre-scan's own per-file hook), so the number
moves while you watch a long walk without costing 27k `/proc` reads.
- **`null` renders NOTHING.** No reading (idle, a run that predates this
phase, the upload job's status, a platform that cannot know) shows no
@@ -1942,6 +2020,7 @@ startup (the DB rows shadow the env, `.env` is never rewritten).
| `BOR_UPLOAD_MAX_MB` | `512` | Cap (MiB) for uploaded source archives (zip-bomb guard) |
| `BOR_SYNC_SESSION_RESET_FILES` | `200` | Files between releases of the import run's SQLAlchemy session (phase 184): every Nth file the walk hands back everything the session still holds (`expunge_all`), so an unchanged-file sync stops keeping one full document row per file walked. `0` = never release — the exact pre-phase-184 behaviour; a negative value refuses startup naming the field. The release also runs once per source, before the two post-passes |
| `BOR_SYNC_PRUNE_BATCH` | `500` | Pruned documents per COMMIT inside the prune (phase 184) instead of one commit at the end of the whole import, so a mass prune releases its deletes as it goes instead of holding one enormous transaction. `0` = one commit at the end — the exact pre-phase-184 behaviour; a negative value refuses startup naming the field. With a non-zero batch a crash mid-prune leaves a **prefix** pruned, and the next sync converges (`seen` is recomputed from disk every run) |
| `BOR_SYNC_PENDING_COUNT` | `1` | What the sync's running `N remaining` counts (phase 194). `1`: before the walk the importer pre-scans the importable files — one streamed sha256 + one scalar `documents.file_hash` read each, in an `asyncio.to_thread` span so the server keeps serving while it runs — and the count is the position in that **predicted change set**, so a ~10,000-file sync that changes 10 files says "10 remaining" instead of "1216 remaining" (the owner report of 2026-10-19). A no-change sync predicts nothing and shows no count part; a first import counts every file, exactly as before. `0` = count every file left to **walk** — the exact pre-phase-194 behaviour, the no-redeploy rollback if the extra read cost ever bites; a negative value refuses startup naming the field. Read once per run (a saved override applies to the next sync) and only by a caller that passes the progress hook: the CLI and the upload job run no scan and pay nothing |
| `BOR_SUGGESTIONS` | built-in list | JSON seed for onboarding chips (shown before the first saved question) |
### Image support (phases 122/123/124/181)