phase: 187_sync_peak_memory_line
Build and Push Containers / build-and-push-app (push) Successful in 1m47s
Build and Push Containers / build-and-push-db (push) Successful in 13s

**Phase 187 — final verification pass: all criteria met, repo 100% green.**

Verified (all four tasks already in `complete/`)
- `app/core/memory.py`: `rss_mb` / `rss_peak_mb` / `cgroup_peak_mb` / `fmt_mb`, pure stdlib, never-raise, **100%** coverage, 24 branch tests.
- Lifecycle lines append-only: `sync: started … rss_mb=`, all three terminals `… rss_mb= rss_peak_mb=` (failed = sanitized one-liner beside the surviving `logger.exception`), `import: summary … rss_mb=`; prefixes byte-identical.
- `GET /api/sync/status`: additive `rss_mb`/`rss_peak_mb` (`int|null`), keyed off the ONE `_sample_rss` (run start, phase transitions, forced terminals, 5 s-throttled hook), `peak >= current` invariant, anonymous 403.
- `sources.js` `fmtSyncPeakClause`: ` · peak <N> MB` trailing the counts line / "Sync cancelled", nothing when null, no new control/live region.

Gates (exact commands)
- `uv run pytest --cov=app --cov-report=term-missing` → **4570 passed**, TOTAL **99%**, `app/api/sync.py` **100%**, `app/core/memory.py` **100%**
- `uv run ruff check .` → clean · `uv run pyright` → **0 errors**
- `uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov` → **5 passed in isolation** (ran twice)
- All 27 touched E2E families re-run **in isolation**, all green (progress_box 8, cancel 3, status_freshness 4, summary_visibility 3, sync_button 3, session_reset 4, git_freeze 2, git_log_stream_dates 5, PDF/image suites, …)

Completion criteria: every checkbox ✅ except the commit + phase-dir move, which belong to the harness.

Documentation
- README reviewed in full (pages, commands, config tables cross-checked against `app/config.py` — 85 env rows, all defaults match; phase-187 ops section, greps, process-vs-cgroup caveat): consistent, no changes needed.
- `AGENTS.md`: **fixed one pre-existing inconsistency** — rule 6 claimed the no-CDN test covers "the index page"; it actually parametrizes 10 pages (all but `/doc-edit.html`).

Notable
- Two failures early in the pass were self-inflicted by running the E2E module concurrently with the full suite on the shared Postgres (TRUNCATE deadlock + one order-dependent `test_overview` flake); both vanish under the documented serial isolation — no code defect, no change made.
- **PLAN §9 still needs the owner's reconciliation** (phase 187 adds the sync memory fields + two status keys; PLAN is never agent-edited — AGENTS rule 10 carries the spec). `TODO.md` holds an unrelated owner note, left untouched.

Next pending phase: `188_chunks_hnsw_index`.
This commit is contained in:
2026-10-16 15:22:51 -04:00
parent 644f7de696
commit c054b47df5
72 changed files with 3826 additions and 96 deletions
@@ -0,0 +1,68 @@
# Phase 189 — Long agent turns save (and share) the whole conversation
**Source:** owner incident 2026-10-16 — the shared chat `0101d2ca-b24c-4d13-9f65-c0837e993c50` (row `8b345ab5-aab3-4412-9642-0f869b3cec4d`) shows ONLY the user's question although the LLM answered it completely; the shared row `be92e464-7918-4907-9186-09d8d98ff2c1` carries the same signature.
**Story:** n/a (incident-derived, owner directive 2026-10-16)
**Depends on:** none functionally (builds on the completed phase-55/83/158/164 saved-chat surface).
## Evidence (diagnosed 2026-10-16, production DB + app logs — read this, not the chat)
1. The row behind the shared token: `n_msgs = 1`, `who0 = user`, `created_at == updated_at` — the row was written EXACTLY ONCE (the send-time auto-save `POST /api/chats`) and never updated. 84 other single-write user-only rows exist (2 shared, incl. this one); the 2-message rows (279) were written >1 time — the pattern is specific to the *last* save point of a conversation.
2. The app log for this row shows the whole sequence: the turn's per-turn line (`thinking=xhigh thinking_chars=23937 tool_calls=33 rounds=34 cap_hit=0 total_ms=400173`, `POST /api/chat 200 stream=1` — the client consumed the FULL 400 s stream, so the done frame landed) followed by **`PUT /api/chats/8b345ab5… 422 Unprocessable Entity`** — the done-time auto-save REJECTED — then `GET /api/chats/… 200`, `POST …/share 200` (×2), `GET 200`. The share only minted a token on the stale row.
3. WHY the 422: the turn's done frame carried **22 sources** (counted from the per-turn log line's `sources=[…]`), but `ChatMessage.sources` is capped at **20** (`app/schemas.py` L1460 — phase 83 A1 "top-N docs + agent reads"). The cited tier is the READ DOCS since phase 119 (`merge_cited_docs`, `app/api/chat.py` L587/L1289 — retrieval top-N ∪ the agent's read docs, NOT bounded by `top_n_docs`), and production runs `BOR_AGENT_MAX_ROUNDS=50` (the app container's env; the config default is 10) — so a long research turn cites 22+ docs routinely and the done-time auto-save 422s. The same turn had **33 tool calls** (`tools` cap 50 — at the production round cap with ZERO headroom) and `segments` entries = 33 tool entries + one thinking entry per contiguous thinking run (`buildSegments`, `app.js` L1775) under `xhigh` thinking over 34 rounds — the `segments` cap of 50 (phase 158, docstring sized "the round cap is 10 → ≤ 20 entries") is exceeded by the same class of turn. `suggestions` (cap 50) and `related` (cap 20) are likewise sized against settings (`suggested_docs`, `related_max_docs`) whose validators are unbounded above.
4. WHY the 422 lost the answer: the phase-55 A2 quiet contract swallows a failed auto-save — a one-line `#send-status` note ("Couldn't save automatically — will try on the next message."), no error banner — and NOTHING retries: the retry is the NEXT save point (next user send / next brain-done / the pagehide partial), which never came (this was the last turn). The row stayed at its pre-turn state.
5. WHY the share froze the loss: `shareCurrentChat`'s LINKED branch (`app.js` L3345) only calls `POST /api/chats/<id>/share` (token mint, `app/api/chats.py` `share_chat`) — it never re-saves the current conversation, so the shared snapshot is whatever the row last held.
6. SECOND loss path (same symptom class): `persistConversation`'s `persisting` double-fire guard (`app.js` L3216) DROPS a save point that arrives while an upsert is in flight ("the next save point retries" — which, per 4., may never come). A done-time save dropped by a still-in-flight send-time save (a 400 s turn on a slow line) leaves the same user-only row.
## Objective
A long agent turn (production `BOR_AGENT_MAX_ROUNDS=50`) saves cleanly at every save point: the saved-chat schema caps move from the round-cap-10 era to the real configuration (A1); a Share never publishes a row the current conversation has outgrown — it saves first (A2); a dropped or failed save self-heals (overlap re-run, one transient retry) and a failed save STAYS visible until the next success (A3/A4).
## Context
- `app/schemas.py` — `ChatMessage` (L1381; `extra="forbid"`, L1458): `text`/`thinking` `max_length=600_000` (phase 164), `sources` `max_length=20` (L1459), `related` `max_length=20`, `suggestions` `max_length=50` (items `_Chip` ≤ 200), `tools` `max_length=50`, `segments` `max_length=50` (L1479), `error` ≤ 500, `image` ≤ 500, `answer_image` `SourceRef`, `answer_text` `max_length=50`, `budget_exhausted`; `SourceRef` (L1260-ish: `source` ≤ 120 / `path` ≤ 1000 / `title` ≤ 500 / `image_url`), `ToolCall` (L1209: `name` ≤ 100, `argument` ≤ 2000, `chars_shown`/`chars_total`/`offset`, the phase-139 `range` + phase-146 `path` additive keys), `SavedChatCreate`/`SavedChatUpdate` (`messages` `min_length=1, max_items=500` — phase 164; the `_require_within_total_budget` validator), `SAVED_CHAT_TOTAL_CHARS_BUDGET = 8_000_000` (L1611). The phase-83 docstrings on the list fields still say "top-N docs + agent reads — the UI shows a handful" — that assumption is RETIRED (see evidence 3).
- `app/api/chat.py` — `merge_cited_docs` (L587), the done-event build (L1402–1447: `cited_refs` = the merged read docs, `related_refs` capped by `related_max_docs`), `app/rag/retriever.py` (the tiering), `app/config.py` (`agent_max_rounds` L279, validator L—: `>= 0`, NO upper bound; `suggested_docs` / `related_max_docs` validators likewise unbounded above; production env `BOR_AGENT_MAX_ROUNDS=50`).
- `app/api/chats.py` — the public write surface (phase 55 A1), `share_chat` (token-only, L379-ish), the 422 is the framework's standard validation response (phase 83 A2: boundary-only, no route changes).
- `frontend/assets/app.js` — `persistConversation` (L3218: the `persisting` guard L3216, the 404→recreate fallback, the A2 quiet contract on failure), `rememberBrainTurn` (L3484 — save point 2, rides L3496), the send save point (L1371), `shareCurrentChat` (L3345: linked → `POST /{id}/share` only; unlinked → `POST /api/chats {messages, share: true}`), `regenerateStaleChat` (L3419 — its own upsert copy), the pagehide partial (L4821, rides `rememberBrainTurn`), `buildSegments` (L1775), `sendStatus` (the `#send-status` live region).
- `tests/unit/test_schemas.py` — the boundary-constant block (L47–75: `SOURCES_CAP = 20`, `SUGGESTIONS_CAP = 50`, `TOOLS_CAP = 50`, `TEXT_CAP = 600_000`, …) and the at-cap/one-over pins per field; `tests/integration/test_chats_api.py` — `test_post_rejects_sources_over_20_and_stores_nothing` (L1862), the oversized-422 pins, the full brain-record pin; `tests/e2e/test_chat_save_payload_limits.py` (oversized-TEXT 422 + small-save 201 — text caps UNCHANGED, this suite is unaffected); `tests/unit/test_save_chat_ui.py` (the JS marker pins for the save/share contract — the A2 quiet-contract pin names the status-note text and the `persisting` guard); `tests/e2e/mock_llm.py` (the deterministic mock turn: single-read tool flow, `slow_llm.py` for timing).
## Design (shared by all tasks — the executor reads this, not the chat)
- **A1 — the caps move to the real configuration (task 01; `app/schemas.py` ONLY, boundary-only — the phase-83 A2 style: NO route change, the 422 stays the framework's standard response).** The five list caps change, every other bound (per-message `text`/`thinking` 600 000, `error` 500, `image` 500, the `SourceRef` field caps, the `ToolCall` field caps, `answer_text` 50, the `max_items=500` messages cap, `SAVED_CHAT_TOTAL_CHARS_BUDGET = 8 000 000`) is UNCHANGED:
- `sources` 20 → **200** — the cited tier is top-N ∪ the agent's READ docs (`merge_cited_docs`), so its size is bounded by `agent_max_rounds` (production 50, validator-unbounded) × calls-per-round, not by `top_n_docs`; 200 = 4× the production round cap (each round typically carries one `read`);
- `related` 20 → **200** — `related_max_docs` is owner-tunable with no upper bound; one `SourceRef`-family cap with `sources`;
- `tools` 50 → **200** — one-or-more calls per round are legal (a round is one model request, not one call); 4× the production round cap;
- `segments` 50 → **450** — ≤ `tools` (200) tool entries + ≤ ~50 thinking runs (one per round, the cap-50 worst case) + margin;
- `suggestions` 50 → **100** — `suggested_docs` is owner-tunable with no upper bound; 20× the default (5); the item cap (200 chars) is unchanged.
The SEC-05 intent survives: the worst-case list bytes are orders of magnitude inside the 8 M TOTAL budget (`200 SourceRefs ≈ 360 KB`, `200 ToolCalls ≈ 440 KB`, `450 segments ≈ 1 MB`), and the fixed boundary still bounds item COUNT for an operator who raises `BOR_AGENT_MAX_ROUNDS` far beyond 50. The `ChatMessage`/`SourceRef` docstrings are updated: the "top-N + a handful" sizing rationale is replaced by the `merge_cited_docs` / `agent_max_rounds` reality (evidence 3), the phase-83 history KEPT (the 32 000 mirror is phase 164's 600 000), and a note that the 8 M total budget is the byte bound. The unit constants (`SOURCES_CAP`, `TOOLS_CAP`, `SUGGESTIONS_CAP`) and the integration pins move with the values.
- **A2 — Share saves first (task 02; `shareCurrentChat`, `app.js` L3345).** The LINKED branch upserts the CURRENT conversation BEFORE minting the token — the SAME upsert semantics as `persistConversation` (PUT; a 404 unlinks and retries as a create), then `POST /api/chats/<id>/share`. An upsert FAILURE (non-ok or network) ABORTS the share with the existing neutral error banner ("Couldn't share the conversation — try again.") — NO token, NO toast, `shareBtn` released in the `finally` (the never-stale contract, PLAN §7.4). The UNLINKED branch is unchanged (its `POST {messages, share: true}` already saves the current conversation in one commit). Docstring: sharing a conversation that the row has not yet caught up would publish a truncated snapshot (the incident) — the upsert-first ordering makes the shared page the current conversation.
- **A3 — a dropped save re-runs (task 03; `persistConversation`, `app.js` L3216–3260).** `persisting` stays the one-upsert-at-a-time guard, but a save point that arrives while an upsert is in flight sets a module-level `savePending = true` instead of vanishing; the in-flight upsert's `finally` (after `persisting = false`) re-runs `persistConversation()` once when `savePending` is set (the flag is cleared first; the re-run is a fresh, serialized call — never concurrent with the upsert it trails). The re-run carries the LATEST `conversation` (the array is read at call time), so the dropped done-time save lands with the brain record. No new save points are added — the existing ones (send, `rememberBrainTurn`/pagehide, regenerate) are unchanged.
- **A4 — a failed save stays visible and retries once (task 03).** On failure (non-ok or network), the existing one-line note is kept AND a module flag `saveFailedSticky = true` set — the note persists until the NEXT SUCCESSFUL save (a success clears the flag and resets `#send-status` to empty when the flag was set; other flows may overwrite the note in the meantime — best-effort visibility, the flag + the next save point are the guarantee). A TRANSIENT failure (network error or 5xx status) additionally schedules ONE retry after 5 000 ms (a `setTimeout` + a one-shot `retryScheduled` guard so overlapping failures never stack retries; the retry is a plain `persistConversation()` — the LATEST conversation, the same guards). A 4xx NEVER retries (deterministic — the cap fix in task 01 is the remedy; the retry would 422 again). The A2 contract's shape is unchanged: still a one-line `#send-status` note, still NO error banner, the turn never blocks — only the note's lifetime and the one transient retry are added. `regenerateStaleChat`'s own upsert copy is LEFT AS IS (its failure path already errors visibly through the turn's banner state, and A1 removes the 422 class it could hit).
## Tasks
1. `01_schema_cap_realign.md` — the five list-cap moves + docstring rationale (A1), the unit boundary pins at the NEW values, the incident-payload integration pin (22 sources / 51 segments / 201 tools now 201/200), the oversized-422 pins at the new boundaries.
2. `02_share_saves_first.md` — `shareCurrentChat`'s linked branch upserts before minting the token, aborts the share on an upsert failure; the JS marker pins.
3. `03_persist_rerun_sticky.md` — the `savePending` overlap re-run, the sticky failure note, the one transient retry; the JS marker pins.
4. `04_e2e_and_gates.md` — the dedicated Playwright suite (the incident regression at the wire + the overlap / share-saves-first behavior through the real UI), the full gates, the docs pass, the atomic commit.
## Testing & Quality
- **Unit:** `tests/unit/test_schemas.py` — the NEW boundary pairs per field (200/201 `sources`, 200/201 `related`, 200/201 `tools`, 100/101 `suggestions`, 450/451 `segments`), the realistic-payload round-trip pin EXTENDED (a 22-source / 34-tool / 40-segment brain record validates and round-trips byte-identically), every pre-phase pin green. `tests/unit/test_save_chat_ui.py` — the A2/A3/A4 marker pins (the upsert-before-share ordering in `shareCurrentChat`'s linked branch, the abort-on-failure path, `savePending` + the `finally` re-run, the sticky flag + the 5 s one-shot retry, the 4xx-no-retry distinction; the existing A2 quiet-contract pins stay green — the note text and the "no error banner" shape are unchanged).
- **Integration:** `tests/integration/test_chats_api.py` — anonymous `POST`/`PUT` with the INCIDENT payload shape (22 `sources`, 33 `tools`, 51 `segments` incl. thinking runs) → **201/200** (pre-fix: 422); the over-cap pins move to the new boundaries (201 `sources` → 422, no row; 201 `tools` → 422; 451 `segments` → 422; 101 `suggestions` → 422); the full brain-record pin + the stale/share pins stay green.
- **E2E (AGENTS rule 4, task 04):** `tests/e2e/test_chat_save_long_turns.py`, run **in isolation** — (a) the incident regression at the wire through `page.request` (22-source brain record → 201, pre-fix 422); (b) a real UI mock turn's done-time auto-save lands (row: 2 messages); (c) the overlap re-run (the send-time save's PUT delayed 3 s via a Playwright route, the mock turn settles in flight → the dropped done-time save re-runs → final row 2 messages); (d) share-saves-first (with the auto-saves still delayed, the Share click → the shared page renders the BRAIN message, not just the question).
- **Regression:** `tests/e2e/test_chat_save_payload_limits.py`, `tests/e2e/test_chat_history.py`, `tests/e2e/test_share_chat.py` (the phase-51/55 share contract) green unedited.
- **Coverage:** **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`); the changed file is `app/schemas.py` (declarative — exercised by every unit/integration pin); the frontend change is marker-pinned per the house JS convention.
- **Lint/types:** `uv run ruff check . && uv run pyright`.
## Documentation & Consistency
- Final pass reviews `README.md` and `AGENTS.md` in their entirety against the post-phase app and fixes every inconsistency found, pre-existing ones included.
- `README.md`: the saved-chat section gains the ops note — a long turn (many cited docs / tool rounds) saves cleanly (the caps now follow the agent's real round cap), Share saves the current conversation before minting the token, a failed auto-save stays visible until the next success and retries once on a transient failure (task 04). `AGENTS.md`: nothing to change unless the final pass finds the phase-55 save contract described elsewhere (the save points are not enumerated in AGENTS.md — the UI-Structure rule's "in-flight turn" clause is untouched; verify, do not add).
## Completion Criteria
- [ ] `ChatMessage` list caps: `sources`/`related`/`tools` 200, `suggestions` 100, `segments` 450; every other bound unchanged; the docstring rationale updated (A1).
- [ ] The incident payload (22 sources / 33 tools / 51 segments) saves 201/200 anonymously; over-cap variants 422 with no row stored (unit + integration pins).
- [ ] A linked Share upserts the current conversation before minting the token and aborts (banner, no token) on an upsert failure; the unlinked branch is byte-identical (A2, marker pins).
- [ ] A save point dropped by an in-flight upsert re-runs once after it settles (A3, marker pin); a failed save's note persists until the next success, one 5 s retry on network/5xx only (A4, marker pins).
- [ ] `uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov` green **in isolation** (the four scenarios); `test_chat_save_payload_limits.py`, `test_chat_history.py`, `test_share_chat.py` green unedited.
- [ ] `uv run pytest` 100% green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] `git diff --stat` limited to `app/schemas.py`, `frontend/assets/app.js`, the test files, the phase files (NO `app/api/chats.py` diff, no migration, no new dependency).
- [ ] One atomic `--no-gpg-sign` commit (e.g. `fix(chats): save long agent turns — realign schema caps, share saves first, dropped saves re-run`); phase dir moved to `.agents/phases/complete/`.
## Owner action (data repair — NOT code, recorded here so the phase's commit message can cite it)
The two affected shared rows (`8b345ab5…`, `be92e464…`) still hold the user-only snapshot. Their brain answers were never persisted (the SSE bytes are not stored server-side). If the owner's browser still carries the conversations (`bor.chat.v1` localStorage restore), opening the chat page (which re-links to the row) and letting the FIXED client's next save point (or a Share — now save-first) fire restores the full content through the existing upsert; otherwise the answers are unrecoverable and the rows may be deleted from History.
@@ -0,0 +1,32 @@
# Task 01 — The saved-chat list caps move to the real agent configuration (A1)
**Phase:** `189_chat_save_long_turns` · **Source:** owner incident 2026-10-16
**Story:** n/a
## Objective
Raise the five `ChatMessage` list caps from the round-cap-10 era to the production `BOR_AGENT_MAX_ROUNDS=50` reality (the incident turn's 22-source done frame 422'd at the old 20 cap), update the sizing rationale in the docstrings, and move the boundary pins — so a long research turn's done-time auto-save lands instead of 422ing into the phase-55 quiet-failure, while the SEC-05 boundedness (fixed item-count boundary + the 8 M total budget) survives.
## Work
1. `app/schemas.py` — `ChatMessage` (L1381; the fields at L1458–1481):
- `sources: list[SourceRef] | None = Field(default=None, max_length=200)` (was 20)
- `related: list[SourceRef] | None = Field(default=None, max_length=200)` (was 20)
- `suggestions: list[_Chip] | None = Field(default=None, max_length=100)` (was 50; the `_Chip` 200-char item cap is unchanged)
- `tools: list[ToolCall] | None = Field(default=None, max_length=200)` (was 50)
- `segments: list[ChatSegment] | None = Field(default=None, max_length=450)` (was 50; the comment's "max_length=50 (the phase-83 voice): far past any realistic turn — the round cap is 10 → ≤ 20 entries; 50 matches the tools cap" is REPLACED with the phase-189 sizing: ≤ 200 tool entries (4× the production round cap of 50 — a round is one model request, one-or-more calls per round are legal) + ≤ ~50 thinking runs (one per round) + margin)
- **UNCHANGED, pinned as-is:** `text`/`thinking` 600 000, `error` 500, `image` 500, `answer_text` 50, the `SourceRef` field caps (120/1000/500 + `image_url`), the `ToolCall` field caps (name 100 / argument 2000 / the additive keys), `SavedChatCreate`/`SavedChatUpdate` `max_items=500`, `SAVED_CHAT_TOTAL_CHARS_BUDGET = 8_000_000` and `_require_within_total_budget` (the verbatim-block accounting untouched).
- The `ChatMessage` class docstring: the phase-83 paragraph's "(sources ≤ 20 — top-N docs + agent reads; related ≤ 20 — the related-doc tier, same ref shape; suggestions ≤ 50 chips of ≤ 200 chars; tools ≤ 50 — one entry per tool call, the round cap is 10)" is UPDATED — the cited tier has been the READ DOCS since phase 119 (`merge_cited_docs`, `app/api/chat.py` L587): retrieval top-N ∪ the agent's read docs, bounded by `agent_max_rounds` (production 50 via `BOR_AGENT_MAX_ROUNDS`, the validator L— unbounded above), so the caps are `sources`/`related`/`tools` 200 (4× the production round cap) and `suggestions` 100 (20× the default 5; `suggested_docs` is tunable, unbounded above), `segments` 450; the phase-83 history (the 32 000 mirror raised to 600 000 with phase 164) is KEPT; one sentence added: the fixed boundary bounds item COUNT (an operator raising `BOR_AGENT_MAX_ROUNDS` far past 50 still hits a boundary) while `SAVED_CHAT_TOTAL_CHARS_BUDGET` is the byte bound (worst-case list bytes ≈ 360 KB / 440 KB / 1 MB — orders of magnitude inside 8 M).
2. `tests/unit/test_schemas.py` — the boundary-constant block (L47–75): `SOURCES_CAP = 200`, `TOOLS_CAP = 200`, `SUGGESTIONS_CAP = 100` (a `RELATED_CAP = 200` and `SEGMENTS_CAP = 450` constant added in the same style); the existing at-cap/one-over pins (L145–216 and the `segments` pins at L922–1009) now bind the NEW values with no edits (they read the constants); the realistic-payload round-trip pin (around L563) is EXTENDED with the incident shape: a brain record carrying 22 `sources`, 33 `tools`, and 40 `segments` (33 tool entries + 7 thinking entries) validates and `model_dump()`s byte-identically (the `_drop_image_when_absent` omission rules untouched — a record with all the keys present keeps them).
3. `tests/integration/test_chats_api.py` — `test_post_rejects_sources_over_20_and_stores_nothing` (L1862) is RENAMED/retargeted to the new boundary (`test_post_rejects_sources_over_200_and_stores_nothing`: 201 `SourceRef` items → 422, no row); NEW pins: (a) `test_post_accepts_the_incident_payload_shape` — an anonymous `POST /api/chats` with a 2-message conversation (user + a brain record carrying 22 `sources`, 33 `tools`, 51 `segments` = 33 tool entries + 18 thinking entries — the incident turn's shape) → **201**, the row's `messages` round-trips all three lists intact; (b) the same shape through `PUT /api/chats/<id>` → **200**, the row's message count bumps to the new array; (c) `test_put_rejects_over_cap_lists_and_leaves_row_unchanged` — 201 `tools` / 451 `segments` / 101 `suggestions` each → **422** and the row's stored messages are byte-identical (the 422 is the framework's standard body — the phase-83 A2 shape, no custom error copy). Every pre-existing pin (the full brain-record pin, the stale/share pins, the 500-item messages cap, the total-budget pins) stays green unedited.
4. Verify: `uv run pytest tests/unit/test_schemas.py tests/integration/test_chats_api.py -v`; `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
## Testing & Quality
- Unit: the new boundary pairs (200/201 `sources`, 200/201 `related`, 100/101 `suggestions`, 200/201 `tools`, 450/451 `segments`) pinned at BOTH boundaries; the extended realistic round-trip pin.
- Integration: the incident payload shape 201/200s anonymously (pre-fix: 422); the over-cap 422 pins at the new boundaries with no row stored / row unchanged; the pre-existing pins green unedited.
- Coverage: **>90%** on `app/` (the changed file is `app/schemas.py` — declarative, exercised by every pin).
## Completion Criteria
- [ ] `ChatMessage` list caps are 200/200/100/200/450 (sources/related/suggestions/tools/segments); every other bound unchanged; the docstring rationale is the `merge_cited_docs`/`agent_max_rounds` one with the 8 M-budget sentence.
- [ ] `uv run pytest tests/unit/test_schemas.py tests/integration/test_chats_api.py -v` green — the incident shape 201/200s, the over-cap variants 422 with no row stored, all pre-existing pins green unedited.
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work (the SSE `done` event is untouched — `ChatDoneEvent.sources` has no list cap; the `ChatMessage` caps bind the saved-chat boundary only)
@@ -0,0 +1,31 @@
# Task 02 — Share saves first: a linked Share never publishes a row the conversation has outgrown (A2)
**Phase:** `189_chat_save_long_turns` · **Source:** owner incident 2026-10-16
**Story:** n/a
## Objective
Make `shareCurrentChat`'s linked branch upsert the CURRENT conversation (the same upsert semantics as `persistConversation`) BEFORE minting the share token — so a Share can never freeze a stale row (the incident: the row held only the question, the Share minted a token on it, and the shared page showed no answer) — and abort the share with the existing neutral error banner when the upsert fails. The unlinked branch (save-AND-share in one `POST`) is byte-identical.
## Work
1. `frontend/assets/app.js` — `shareCurrentChat` (L3345), the linked branch (the `if (currentChatId)` arm, ~L3353–3363) becomes, in order:
1. the upsert: `const body = JSON.stringify({ messages: conversation }); const headers = { "Content-Type": "application/json" };` → `res = await fetch(`/api/chats/${currentChatId}`, { method: "PUT", headers, body });` with the SAME 404→recreate fallback as `persistConversation` (L3227–3234): on a 404, `currentChatId = null; res = await fetch("/api/chats", { method: "POST", headers, body });` and, on the 201, re-link (`currentChatId = String((await res.json()).id)`);
2. an upsert FAILURE (non-ok `res` after the fallback, or a network `throw`) → `showErrorBanner("Couldn't share the conversation — try again.")` and `return` (NO token minted, NO toast — the existing failure UI, the existing `finally` still releases `shareBtn.disabled`);
3. only then the existing `POST /api/chats/<id>/share` mint (unchanged) and the existing success paths (clipboard copy → the inline-field fallback, the `#send-status` line, the task-04 toast on BOTH success branches) — byte-identical.
The function docstring gains the phase-189 paragraph: the linked branch upserts first because a Share that minted a token on a row the conversation had outgrown published a truncated snapshot (the incident — the done-time auto-save had 422'd into the quiet-failure and the row held only the question); an upsert failure aborts the share (the owner must never be handed a link to content that is not the current conversation). The unlinked branch's docstring ("one action saves AND shares") is unchanged.
2. `tests/unit/test_save_chat_ui.py` — marker pins (the house JS-marker convention, the module's existing style — no browser):
- `test_share_linked_branch_upserts_before_the_share_mint`: the linked branch of `shareCurrentChat` contains the PUT to `` `/api/chats/${currentChatId}` `` (the `persistConversation`-shaped upsert) positioned BEFORE the `` `/api/chats/${currentChatId}/share` `` fetch in the function body (a string-ordering pin: the PUT line's index < the share-mint line's index), and carries the 404→recreate fallback markers (the `status === 404` arm + the `POST` to `"/api/chats"` with the same `body`/`headers`);
- `test_share_aborts_on_upsert_failure`: the failure arm between the upsert and the mint sets the EXACT neutral banner text `"Couldn't share the conversation — try again."` and `return`s (no `showToast` call between the upsert's failure check and the banner — the toast is success-only, the existing pin stays green);
- `test_share_unlinked_branch_unchanged`: the unlinked branch still POSTs `"/api/chats"` with `share: true` in the body and re-links on the 201 (the existing pins, green unedited);
- the existing phase-55 pins (the always-visible `#share-chat-btn` markup, the neutral error copy, the `showToast` success-only contract, the double-click guard released in the `finally`) stay green unedited.
3. Verify: `uv run pytest tests/unit/test_save_chat_ui.py -v`; `uv run pytest` green.
## Testing & Quality
- Unit (JS markers): the upsert-before-mint ordering, the 404→recreate fallback in the share, the abort-on-failure path (banner, no token, no toast), the unlinked branch unchanged; every pre-existing pin green.
- Coverage: the frontend change is marker-pinned per the house JS convention (no JS runtime in the unit gate); the behavior is E2E-pinned by task 04.
## Completion Criteria
- [ ] `shareCurrentChat`'s linked branch: PUT upsert (with the 404→recreate fallback) → failure abort (neutral banner, no token, no toast) → share mint → the existing success paths byte-identical; the docstring says why.
- [ ] `uv run pytest tests/unit/test_save_chat_ui.py -v` green — the new marker pins + every pre-existing pin.
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work (the unlinked branch, the toast/banner contract, the `share_chat` endpoint — token-only, `app/api/chats.py` UNTOUCHED)
@@ -0,0 +1,34 @@
# Task 03 — Dropped saves re-run; a failed save stays visible and retries once (A3/A4)
**Phase:** `189_chat_save_long_turns` · **Source:** owner incident 2026-10-16
**Story:** n/a
## Objective
Close the two silent-loss paths in `persistConversation`: a save point dropped by the `persisting` double-fire guard (evidence 6 — a done-time save dropped by an in-flight send-time save vanishes until the next save point, which may never come) re-runs once after the in-flight upsert settles; a failed save's status note STAYS until the next success and a transient failure (network / 5xx) retries once after 5 s — while the phase-55 A2 contract's shape is unchanged (one-line `#send-status` note, NO error banner, the turn never blocks, silent success).
## Work
1. `frontend/assets/app.js` — `persistConversation` (L3216–3260) and its guard (L3216):
- `let persisting = false;` stays (the one-upsert-at-a-time guard); add `let savePending = false;` (A3) and `let saveFailedSticky = false; let retryScheduled = false;` (A4) at the same module scope, each with a one-line comment naming the phase-189 decision.
- **A3 — the overlap re-run:** the top guard becomes `if (persisting) { savePending = true; return; }` (a save point that arrives mid-upsert is PENDING, not dropped). At the top of the body, after the guard: `savePending = false;` (a fresh run consumes the flag — the pending re-run is this run when it was set by a re-run). In the `finally` (after `persisting = false;`): `if (savePending) { savePending = false; void persistConversation(); }` — the re-run is a fresh serialized call (it starts only after the in-flight upsert fully settled; never concurrent with it). The re-run reads `conversation` at call time, so the dropped done-time save lands with the brain record. No new save points, no other function touched.
- **A4 — sticky note + one transient retry:** in the failure paths (the `!res.ok` arm and the `catch` arm), after setting the existing note text, set `saveFailedSticky = true;` and — transient only — `if (!retryScheduled && (res.status >= 500 || <network-failure-arm>)) { retryScheduled = true; setTimeout(() => { retryScheduled = false; void persistConversation(); }, 5000); }` (the `catch` arm IS the network-failure arm; the 4xx — every other non-ok — NEVER schedules: a 422 is deterministic, the phase-189 task-01 cap fix is the remedy, and a retry would 422 again; the one-shot `retryScheduled` guard keeps overlapping failures from stacking retries). In the success path (the `staleBanner` clearing block), add: `if (saveFailedSticky) { saveFailedSticky = false; sendStatus.textContent = ""; }` (a success clears the stuck note — the note persists until the next success; other flows may overwrite the note in the meantime, best-effort visibility, the flag + the next save point are the guarantee).
- The `persistConversation` docstring: the "double-fire guard" paragraph is updated — a dropped save point is now PENDING and re-runs once after the in-flight upsert settles (A3); the A2 quiet-contract paragraph gains the two phase-189 lines (the note persists until the next success; one 5 s retry on network/5xx, never on 4xx — the note text and the no-banner shape are UNCHANGED).
- `regenerateStaleChat` (L4439–4460, its own upsert copy) is LEFT AS IS (its failure surfaces through the turn's banner state; A1 removes the 422 class it could hit).
2. `tests/unit/test_save_chat_ui.py` — marker pins (the house JS-marker convention):
- `test_persisting_overlap_sets_pending_and_reruns`: `persistConversation` contains the `savePending = true` assignment inside the `if (persisting)` arm, the `savePending = false` reset at the top of the body, and the `finally` block re-runs `persistConversation()` guarded by `savePending` (the ordering: `persisting = false` before the `savePending` check in the `finally`);
- `test_failed_save_note_is_sticky_until_success`: the failure arms set `saveFailedSticky = true`, and the success path clears the flag + resets `sendStatus` (the pin: the success block contains the `saveFailedSticky` guard);
- `test_transient_failure_retries_once_after_5s`: the retry scheduling is present with the 5000 ms delay, the `>= 500` status condition, the `catch`-arm scheduling, and the one-shot `retryScheduled` guard (set before the `setTimeout`, reset inside it);
- `test_4xx_never_retries`: the retry condition is `res.status >= 500` (a 4xx non-ok schedules nothing — the pin: no other `setTimeout` scheduling in the failure arms);
- the existing phase-55 A2 pins (the one-line note text `"Couldn't save automatically — will try on the next message."`, no error-banner call in `persistConversation`, the silent-success shape, the `persisting` guard) stay green unedited.
3. Verify: `uv run pytest tests/unit/test_save_chat_ui.py -v`; `uv run pytest` green.
## Testing & Quality
- Unit (JS markers): the pending re-run (set/reset/finally-re-run ordering), the sticky-note lifetime, the one-shot 5 s transient retry, the 4xx exclusion; every pre-existing pin green.
- Coverage: the frontend change is marker-pinned per the house JS convention; the behavior is E2E-pinned by task 04 (the overlap scenario).
## Completion Criteria
- [ ] `persistConversation`: an overlapping save point sets `savePending`; the `finally` re-runs once after `persisting = false`; a fresh run consumes the flag.
- [ ] A failed save's note persists until the next success; a network/5xx failure schedules ONE 5 s retry (one-shot guard); a 4xx never retries; the A2 contract's shape (note text, no banner, silent success) is unchanged.
- [ ] `uv run pytest tests/unit/test_save_chat_ui.py -v` green — the new marker pins + every pre-existing pin.
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work (`regenerateStaleChat` untouched; no new save points; `app/api/chats.py` untouched)
@@ -0,0 +1,57 @@
# Task 04 — The E2E suite, the full gates, the docs pass, the commit
**Phase:** `189_chat_save_long_turns` · **Source:** owner incident 2026-10-16
**Story:** n/a
## Objective
Pin the owner-visible contract of the whole phase through the real UI: the incident regression at the wire (a 22-source brain record saves instead of 422ing), a mock turn's done-time auto-save landing, the overlap re-run (a dropped save lands after the in-flight upsert settles), and share-saves-first (a Share with the auto-saves still in flight publishes the BRAIN message, not just the question) — then the full gate, the docs pass, and the atomic commit.
## Work
1. `tests/e2e/test_chat_save_long_turns.py` — the phase's dedicated Playwright suite (the house `conftest` app + `mock_llm.py` pattern, run **in isolation** per AGENTS rule 4; a fresh saved-chat row per test — the house `saved_chats` cleanup helper pattern from `test_chat_history.py`):
- `test_incident_payload_shape_saves_at_the_wire`: through `page.request` (the `test_chat_save_payload_limits.py` pattern — the app's own origin), an anonymous `POST /api/chats` with the INCIDENT shape (a user message + a brain record carrying 22 `sources` (`SourceRef` dicts at realistic column-length values), 33 `tools` (`ToolCall` dicts), 51 `segments` = 33 `{kind: "tool", …}` + 18 `{kind: "thinking", text: …}`) → **201**; a follow-up `PUT` replacing the brain record with the same shape → **200** and the row's `messages` round-trips all three lists (a `GET /api/chats/<id>` admin assertion). (Pre-fix: both 422 — the incident.)
- `test_done_time_auto_save_lands_for_a_mock_turn`: the real UI — send a mock question (the `mock_llm.py` no-tools flow, the `test_chat_history.py` send pattern), wait for the done bubble (the house `wait_for_answer` helper), then `GET /api/chats/<id>` (the row id from the created-response `#…` — the house pattern reads `currentChatId` via `page.evaluate`): the row has **2** messages and the brain record's `text` equals the mock answer.
- `test_dropped_done_time_save_reruns_after_the_inflight_upsert_settles`: `page.route`-delay the FIRST PUT to `/api/chats/**` (the send-time auto-save) by 3 000 ms (a `page.route("**/api/chats/**", handler)` that resolves on `route.fulfill` after a `wait_for_timeout` ONLY for the first matched PUT — a module-level counter; later PUTs pass through untouched), then send the mock question and let the mock turn settle (the done bubble appears in flight — the mock is fast, the upsert is still parked at 3 s). Wait to ~5 s; `GET /api/chats/<id>`: the row has **2** messages (the dropped done-time save re-ran after the parked upsert settled — pre-fix: the row held 1).
- `test_share_saves_first_publishes_the_brain_message`: the same 3 s delay on the first PUT; send the mock question; while the send-time save is still parked (the done bubble has appeared), click `#share-chat-btn` (the house click helper, wait for the toast). The share's own upsert PUT passes the route filter UNdelayed (the counter already consumed by the parked send-time save), mints the token, and the toast lands; open `share_url` (the `test_share_chat.py` pattern — the `/shared/<token>` page, the `#shared-title` visible) and assert the shared page renders the BRAIN answer text (a `.msg .bubble` containing the mock answer string) — pre-fix: the shared page showed only the question. Assert the row (via admin `GET`) has 2 messages.
- Module docstring: the incident's evidence summary (the 422 PUT log line, the 22-source done frame, `created_at == updated_at` single-write row) so a future reader sees WHY these four tests exist.
2. The regression suites (green UNEDITED — do not touch them): `tests/e2e/test_chat_save_payload_limits.py` (oversized-TEXT 422 + small-save 201 — the text caps are unchanged), `tests/e2e/test_chat_history.py`, `tests/e2e/test_share_chat.py`.
3. The full gate, in order: `uv run pytest -v` (unit + integration 100% green); `uv run pytest --cov=app --cov-report=term-missing` (>90% on `app/`); `uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov` (**in isolation**); `uv run ruff check . && uv run pyright` clean.
4. The docs pass (the phase's Documentation & Consistency mandate): review `README.md` and `AGENTS.md` in their entirety against the post-phase app; fix every inconsistency found, pre-existing ones included. `README.md`: the saved-chat section gains the ops note — a long turn (many cited docs / tool rounds) saves cleanly (the caps now follow the agent's real round cap — 200/100/450), a Share saves the current conversation before minting the token (a failed share upsert aborts — no link to a truncated snapshot), a failed auto-save stays visible until the next success and retries once after 5 s on a transient failure (a 4xx never retries). `AGENTS.md`: verify the phase-55 save contract is not described anywhere in it (the save points are not enumerated — the UI-Structure rule's "in-flight turn" clause is untouched); if the final pass finds any stale save-contract sentence, fix it in place (no new rule added).
5. The atomic commit (AGENTS rule 8; the git-commit skill's `--no-gpg-sign` + `LLM-Generated: true` trailer): `git add -A && git commit --no-gpg-sign -m "fix(chats): save long agent turns — realign schema caps, share saves first, dropped saves re-run
The done-time auto-save of a long turn (production BOR_AGENT_MAX_ROUNDS=50;
the incident turn cited 22 docs, 33 tool calls, 34 rounds) 422'd at the
phase-83 list caps sized for the round-cap-10 era (sources ≤ 20, tools ≤ 50,
segments ≤ 50), and the phase-55 quiet-failure swallowed it: nothing retried,
so the row kept its pre-turn state — and a Share minted a token on that stale
row, publishing a conversation without its answer (incident 2026-10-16,
shared 0101d2ca… / row 8b345ab5…).
- A1: ChatMessage list caps follow the real configuration — sources/related/
tools 200 (4× the production round cap; the cited tier is the read docs,
merge_cited_docs), suggestions 100, segments 450; every other bound and the
8 M total budget unchanged (SEC-05 boundedness survives).
- A2: a linked Share upserts the current conversation before minting the
token and aborts on an upsert failure — never a link to a truncated
snapshot.
- A3: a save point dropped by the in-flight guard re-runs once after the
upsert settles.
- A4: a failed save's note persists until the next success; one 5 s retry on
network/5xx (a 4xx never retries).
LLM-Generated: true"`.
6. Move the phase directory: `mv .agents/phases/todo/189_chat_save_long_turns .agents/phases/complete/` and `git add .agents && git commit --no-gpg-sign -m "chore(agents): complete phase 189 (chat save long turns)"` — OR fold the phase move into the atomic commit per the house convention (check the last 3 phase commits' shape and match it; the AGENTS rule 8 says one atomic commit per phase including the phase files — if the house convention is one commit, `git add` the phase dir into step 5's commit and skip step 6's commit, keeping the `mv`).
## Testing & Quality
- E2E: the four scenarios above, in isolation.
- Regression: the three listed suites green unedited.
- Coverage / lint / types: the full gate (step 3).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov` green **in isolation** — the incident shape 201/200 at the wire, the mock turn's done-time save lands, the dropped save re-runs (final row 2 messages), the share with in-flight auto-saves publishes the brain message on the shared page.
- [ ] `test_chat_save_payload_limits.py`, `test_chat_history.py`, `test_share_chat.py` green UNEDITED.
- [ ] `uv run pytest` 100% green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] README.md / AGENTS.md reviewed in full; the saved-chat ops note in place; no stale save-contract sentence left.
- [ ] `git diff --stat` (before the commit) limited to `app/schemas.py`, `frontend/assets/app.js`, `tests/unit/test_schemas.py`, `tests/unit/test_save_chat_ui.py`, `tests/integration/test_chats_api.py`, `tests/e2e/test_chat_save_long_turns.py`, `README.md`, the phase files (NO `app/api/chats.py` diff, no migration, no new dependency).
- [ ] the atomic `--no-gpg-sign` commit exists (message above); the phase dir is in `.agents/phases/complete/`.
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
- [ ] no behavior change in completed phases (the SSE `done` event, the `share_chat` endpoint, the unlinked share branch, the phase-55 A2 contract's shape — all unchanged)
@@ -0,0 +1,25 @@
**Phase 187 — final verification pass: all criteria met, repo 100% green.**
Verified (all four tasks already in `complete/`)
- `app/core/memory.py`: `rss_mb` / `rss_peak_mb` / `cgroup_peak_mb` / `fmt_mb`, pure stdlib, never-raise, **100%** coverage, 24 branch tests.
- Lifecycle lines append-only: `sync: started … rss_mb=`, all three terminals `… rss_mb= rss_peak_mb=` (failed = sanitized one-liner beside the surviving `logger.exception`), `import: summary … rss_mb=`; prefixes byte-identical.
- `GET /api/sync/status`: additive `rss_mb`/`rss_peak_mb` (`int|null`), keyed off the ONE `_sample_rss` (run start, phase transitions, forced terminals, 5 s-throttled hook), `peak >= current` invariant, anonymous 403.
- `sources.js` `fmtSyncPeakClause`: ` · peak <N> MB` trailing the counts line / "Sync cancelled", nothing when null, no new control/live region.
Gates (exact commands)
- `uv run pytest --cov=app --cov-report=term-missing` → **4570 passed**, TOTAL **99%**, `app/api/sync.py` **100%**, `app/core/memory.py` **100%**
- `uv run ruff check .` → clean · `uv run pyright` → **0 errors**
- `uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov` → **5 passed in isolation** (ran twice)
- All 27 touched E2E families re-run **in isolation**, all green (progress_box 8, cancel 3, status_freshness 4, summary_visibility 3, sync_button 3, session_reset 4, git_freeze 2, git_log_stream_dates 5, PDF/image suites, …)
Completion criteria: every checkbox ✅ except the commit + phase-dir move, which belong to the harness.
Documentation
- README reviewed in full (pages, commands, config tables cross-checked against `app/config.py` — 85 env rows, all defaults match; phase-187 ops section, greps, process-vs-cgroup caveat): consistent, no changes needed.
- `AGENTS.md`: **fixed one pre-existing inconsistency** — rule 6 claimed the no-CDN test covers "the index page"; it actually parametrizes 10 pages (all but `/doc-edit.html`).
Notable
- Two failures early in the pass were self-inflicted by running the E2E module concurrently with the full suite on the shared Postgres (TRUNCATE deadlock + one order-dependent `test_overview` flake); both vanish under the documented serial isolation — no code defect, no change made.
- **PLAN §9 still needs the owner's reconciliation** (phase 187 adds the sync memory fields + two status keys; PLAN is never agent-edited — AGENTS rule 10 carries the spec). `TODO.md` holds an unrelated owner note, left untouched.
Next pending phase: `188_chunks_hnsw_index`.
@@ -0,0 +1,145 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
.................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 231 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 318 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 865 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6932 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,16 @@
**Report — phase 187 / task 01 (`app/core/memory.py`)**
- **Implemented:** `app/core/memory.py` — pure-stdlib (no `app.` imports), mirroring `response_budget.py`'s shape:
- `rss_mb()` — parses `VmRSS` (kB→MB floor) from `/proc/self/status`; any `OSError`/`KeyError`/`ValueError`/`IndexError` or missing line falls back to the `getrusage` high-water proxy; `None` only when both are unavailable.
- `rss_peak_mb()` — `ru_maxrss // 1024`, guarded `import resource` (`None` on platforms without it), never-raise wrapper around `getrusage`.
- `cgroup_peak_mb()` — `/sys/fs/cgroup/memory.peak` bytes→MB, `None` otherwise; docstring carries the one-time container/cgroup caveat (`file` vs `anon`, systemd `3.3G` anecdote, LOCKED A1).
- `fmt_mb()` — `"?"` for `None`, `"0"` for 0; module docstring documents the incident, helpers, never-raise contract, "Pure stdlib" line.
- **Tests:** `tests/unit/test_core_memory.py` — 24 tests: real Linux `/proc` read (fallback pinned to a sentinel to prove the parse path), kB→MB floor, missing/unparseable/value-less/no-`VmRSS`-file fallbacks, resource stubs, guarded-import branch covered via `importlib.reload` with `sys.modules["resource"]=None` (no pragma — house style), cgroup present/absent/garbage, `fmt_mb`, AST purity pin.
- **Gates (all green):**
- `uv run pytest tests/unit/test_core_memory.py -v` → 24 passed; `--cov=app.core.memory --cov-branch` → **100%** (0 missed stmts/branches).
- `uv run pytest --cov=app --cov-report=term-missing` → **4555 passed**, `app/` TOTAL **99%** (>90% gate).
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- `uv run pytest tests/e2e/test_smoke.py --no-cov` → 3 passed (sanity).
- **Docs:** no doc impact — nothing user-visible yet (log lines / status keys / README grep land in tasks 02–03, docs pass in task 04).
- **Decisions:** guarded `resource` import typed as `ModuleType | None`; path constants (`PROC_STATUS_PATH`, `CGROUP_PEAK_PATH`) patched via `monkeypatch` instead of global `open` mocks; `KeyError` kept in the suppress list per spec with a docstring note that the loop form can't raise it.
- **Next pending task:** `187_sync_peak_memory_line/02_sync_log_lines.md`.
@@ -0,0 +1,145 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................................................ [ 99%]
................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 318 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 864 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6904 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,16 @@
**Implemented (task 02 — `187_sync_peak_memory_line`)**
- `app/api/sync.py`: `sync: started … rss_mb=N`; `sync: done detail=… rss_mb=N rss_peak_mb=M`; `sync: cancelled by user … rss_mb=N rss_peak_mb=M`; failed funnel keeps `logger.exception` (traceback) and gains `logger.error("sync: failed state=failed files_done=%d files_total=%d rss_mb=%s rss_peak_mb=%s error=%s")` using the **already-sanitized** `_status.error`.
- `app/rag/importer.py`: `ImportSummary.log()` line gains trailing ` rss_mb=%s`; `format_counts()` untouched (CLI consumers byte-identical).
- All rendering single-sourced through `app.core.memory.fmt_mb` (`?` for unknown); strictly append-only (LOCKED A2) — module docstrings updated.
- Tests: new `test_success_lifecycle_lines_carry_memory_fields`; pins in `test_sync_api.py` (started/done/cancel/failed prefixes byte-identical + `rss_mb=(\?|int)` tails, traceback survives, `error=` masked), `test_importer_e2e.py` (both runs end in ` rss_mb=<int|?>`, earlier fields intact), `tests/unit/test_importer.py` (line prefix pin + `formats=` no longer last), `tests/unit/test_sync_button.py` (cancel-line fullmatch).
- Also documented a real nuance found while verifying: on Linux `rss_mb` can print ~1 MB **above** `rss_peak_mb` (kernel high-water accounting + MB flooring) — noted in `app/core/memory.py` + README so nobody "fixes" a correct log line.
**Gates (all 100% green)**
- `uv run pytest` → **4556 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%**, `app/api/sync.py` 100%, `app/core/memory.py` 100% (gate >90%)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
- Regression insurance: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → 8 passed (no UI touched; phase E2E belongs to task 04)
**Docs**: `README.md` — new “Operational notes: the sync’s memory (phase 187)” (greps, field meanings, process-vs-cgroup/page-cache caveat, append-only note) + a bullet in *Sync from the UI*. `AGENTS.md` rule 10 — parenthetical extended with the sync memory fields + `grep ' :: ' | grep ' rss_peak_mb='`. `PLAN.md` §9 (`import: summary … formats=…`) now trails `rss_mb=` — **flagged for the owner to reconcile, not edited**.
**Next pending task**: `.agents/phases/todo/187_sync_peak_memory_line/03_status_key_and_ui.md` (`SyncStatus` fields + sampling, `rss_mb`/`rss_peak_mb` on `/api/sync/status`, `sources.js` ` · peak <N> MB` clause).
@@ -0,0 +1,145 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
.................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 206 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 318 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 865 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6907 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,14 @@
All gates are green. Final state:
**Backend** (`app/api/sync.py`)
- `SyncStatus.rss_mb` / `rss_peak_mb` (`int | None`, default `None`, reset with each run) written only through the ONE sampler `_sample_rss(force=False)`: `time.monotonic()` guard makes an unforced call a no-op inside `_RSS_SAMPLE_INTERVAL_S = 5.0`; forced at run start, all three `phase` transitions and every terminal, throttled in the per-file hook.
- `GET /api/sync/status` returns both keys additively (null = "no reading", never 0); success / cancelled / failed terminals log exactly the pair they report, so `sync: done …` and the payload are one instant.
**Frontend** (`frontend/assets/sources.js`)
- ONE builder `fmtSyncPeakClause(status)` (`status.rss_peak_mb ?? status.rss_mb`, non-finite/negative → `""`), appended by `setSyncProgress` to the two result strings that already exist (`fmtSyncResult(detail)`, `"Sync cancelled"`) — no new control/element/live region, no clause in running labels, `applySyncFailure`, `applySyncIdle` or the upload settles.
**Tests** — unit: `_sample_rss` sampling/throttle/force/null, start+phase+terminal sampling, terminal log==payload, key-set pin (old ∪ {`rss_mb`,`rss_peak_mb`}), idle pin, source pins + Node render of the clause. Integration: additive-keys/null-before-run, success + failed terminal pairs (`peak >= rss` on Linux), cancelled pair, anonymous `403` guard. E2E: added `tests/e2e/sync_helpers.py` (`PEAK_CLAUSE`, `counts_line()`, `strip_peak_clause()`) and updated 27 sync-facing suites that asserted exact result-line equality — **documented exact-string update**: the clause is optional trailing text there, and `test_sync_session_reset.py` strips it before its cross-app byte-identity comparison.
**Gates** — `uv run pytest --cov=app` → EXIT 0, TOTAL 99%, `app/api/sync.py` 100%, `app/core/memory.py` 100%; `ruff check .` clean, `pyright` 0 errors; E2E in isolation (`--no-cov`): `test_sync_summary_visibility` 3 passed, `test_sync_progress_box` 8 passed, `test_sync_status_freshness` 4 passed, `test_sync_button` 3 passed, and the other 23 patched suites all EXIT 0 (390px layout pin still holds in `test_sync_label_mobile`).
**Docs** — `README.md` (status keys, the ` · peak N MB` clause, sampling/throttle, null renders nothing) and `AGENTS.md` rule 10 + rule 5 UI-structure clause. No git index/history changes; `03_status_key_and_ui.md` left in `todo/` for the harness.
@@ -0,0 +1,145 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 227 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 318 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 865 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6928 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
All gates are green. Final state:
**Implemented (task 04)**
- `tests/e2e/test_sync_peak_memory_visible.py` — new mock-only module (own app port 8204 / slow-LLM proxy 8910, dedicated `BOR_SOURCES_DIR`, DB truncated before **and** after each test, admin form login, JS console/page-error guard). 5 tests: success result line `2 added · peak N MB` (wording intact, inside the sole `role=status`/`aria-live` region, no new element, DOM number == payload `rss_peak_mb`, survives reload); payload additive (pre-187 key set + typed `rss_mb`/`rss_peak_mb`, `peak >= current`, kept in the terminal, **no clause while running**); anonymous `GET /api/sync/status` → `403 {"detail":"admin only"}` + no memory text in the shell; cancelled terminal stays neutral `Sync cancelled · peak N MB`; clause fits 375 px with zero horizontal overflow.
- Fixture renamed to `memline-fixture/notes/mem-*.md` + a documented naming rule: nothing on the page may contain "peak" except the clause, so the "no clause while running" pin can't be satisfied by a file name.
**Defect fixed (found by this phase's gates, in task 03's code)** `tests/integration/test_sync_api.py` intermittently failed: `ru_maxrss` refreshes lazily, so a raw peak could sit **below** the current reading (`164` vs `162`). `app/api/sync.py::_sample_rss` (the ONE sampling point) now lifts a lagging peak to the current reading — never fabricating an unknown one — so every surface satisfies `peak >= current`; slack in the integration/E2E pins went 1 MB → 0, two new unit tests pin both branches.
**Gates** `pytest --cov=app`: **4570 passed**, `TOTAL 99%`, `app/api/sync.py` and `app/core/memory.py` **100%** · `ruff check .` clean · `pyright` 0 errors · target E2E **5 passed in isolation** (+2 repeat runs) · `test_sync_progress_box`, `test_sync_status_freshness`, `test_sync_cancel`, `test_sync_summary_visibility`, `test_sync_button` green in isolation · mutation-checked (drop success clause → tests 1&5 fail; drop cancel clause → test 4 fails; leak clause into the running label → all 5 fail), `sources.js` verified byte-identical to the pre-mutation state.
**Docs** `README.md` (sync bullet, "States" settle line, *Operational notes: the sync's memory* invariant) and `AGENTS.md` rule 10 now state the `peak >= current` invariant, the terminal-only clause and the new suite; fixed pre-existing page-naming drift (3× "Sources page" → "RAG page (`/sources.html`)"); README/AGENTS path + `BOR_*` default checks report 0 issues.
**Deviations / flags**
1. Per the harness protocol I did **not** commit and did **not** move `todo/187_sync_peak_memory_line/` → `complete/` (tasks 01–03 are already in `complete/`; `00_phase.md` + `04_e2e_peak_memory.md` remain). Everything is staged-free in the working tree for the harness's single commit — suggested message: `feat(observability): report the sync's RSS and peak memory in the log, the status payload and the result line`.
2. **PLAN §9 reconciliation (owner action, PLAN.md untouched):** §9 still lists only the per-turn line and the `import: summary` line; phase 187 adds the sync lifecycle memory fields (`rss_mb` on `sync: started` + summary, `rss_mb`/`rss_peak_mb` on all three terminals, plus the two additive `/api/sync/status` keys). §9 needs a matching bullet, or AGENTS rule 10 stays the only place the sync lines are specified.
3. **Pre-existing, not from this phase:** running an E2E module and `tests/unit/test_sync_button.py` in the *same* pytest invocation fails (`asyncio.run() cannot be called from a running event loop`) because pytest-playwright's session-scoped loop leaves the main thread marked as running; reproduced with HEAD's unmodified unit file + `test_sync_cancel.py`. The documented gates never mix those paths (`testpaths` excludes `tests/e2e`), so I left it alone rather than touch shared fixtures.
**Next pending task:** `.agents/phases/todo/188_chunks_hnsw_index/01_hnsw_migration.md`.
@@ -0,0 +1,145 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
.................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 231 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 318 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 865 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6932 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+41 -4
View File
@@ -71,14 +71,23 @@
settle it), the Copy button's clipboard content equal to the server settle it), the Copy button's clipboard content equal to the server
string exactly, and the shared page's panel Copy button + plain string exactly, and the shared page's panel Copy button + plain
`/document.html` link the ONE owner-directed exception to that page's `/document.html` link the ONE owner-directed exception to that page's
zero-controls rule) — phase 182 task 07) and zero-controls rule) — phase 182 task 07), and the sync's peak-memory
meets WCAG 2.1 AA basics (semantic clause is TRAILING TEXT on the result line that already exists
(` · peak <N> MB` on the RAG page's `#sync-progress` counts line
or "Sync cancelled", written by the ONE `fmtSyncPeakClause` off
`rss_peak_mb ?? rss_mb` — no new control, no new live region, no new
element, no clause in a running label or in the failed terminal's
empty box, and NOTHING rendered when the pair is null — phase 187
task 03) and meets WCAG 2.1 AA basics (semantic
landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the
stream). stream).
6. **"No CDN Rule":** all CSS, JS, fonts, and images must be served statically 6. **"No CDN Rule":** all CSS, JS, fonts, and images must be served statically
from within the FastAPI application. No `<script src="https://…">` or from within the FastAPI application. No `<script src="https://…">` or
`<link href="https://…">` tags in HTML templates unless bundled locally. `<link href="https://…">` tags in HTML templates unless bundled locally.
An integration test enforces this on the index page. An integration test enforces this on every served page except
`/doc-edit.html` (the parametrized no-CDN check in
`tests/integration/test_api.py` — the shell, the viewer, the login and
the shared page).
7. **"Debugpy Check":** any new entrypoint must keep the conditional import — 7. **"Debugpy Check":** any new entrypoint must keep the conditional import —
`debugpy` is imported **only** when `DEBUGPY=1` (see `debugpy` is imported **only** when `DEBUGPY=1` (see
`app/core/debugging.py`); default off, never imported otherwise. `app/core/debugging.py`); default off, never imported otherwise.
@@ -102,7 +111,35 @@
`api method=… path=… status=… bytes=… enc=… duration_ms=…` line per `api method=… path=… status=… bytes=… enc=… duration_ms=…` line per
`/api/*` response, INFO at/over `BOR_LOG_LARGE_RESPONSE_BYTES`, read it `/api/*` response, INFO at/over `BOR_LOG_LARGE_RESPONSE_BYTES`, read it
with `grep ' :: api '`, audit it with with `grep ' :: api '`, audit it with
`uv run python -m scripts.api_payload_audit`, phase 167); UI-side, `uv run python -m scripts.api_payload_audit`, phase 167) **and** the
sync's memory fields (phase 187 — `app/core/memory.py` reads PROCESS
RSS, never the host's cgroup figure, and never raises: a `None`
renders `?` through the ONE `fmt_mb` helper. `rss_mb=` is appended to
`sync: started …` and to the importer's `import: summary …`, and
`rss_mb=` + `rss_peak_mb=` to all three terminals (`sync: done …`,
`sync: cancelled by user …`, and the sanitized one-line
`sync: failed state=failed … error=…` that sits beside — never in
place of — the `logger.exception` traceback). Append-only: no
pre-existing field renamed, reordered or re-typed, so every older
grep still matches; read them with
`grep ' :: ' server.log | grep ' rss_peak_mb='`. Phase 187 task 03
puts the SAME pair on the status: `SyncStatus.rss_mb` /
`rss_peak_mb`, written by the ONE `_sample_rss` — forced at the run
start, at every `phase` transition and in every terminal (which keep
the FINAL pair beside the final counts, the phase-64 convention, and
log exactly what the payload reports) — and throttled to one read per
`_RSS_SAMPLE_INTERVAL_S` (5 s) from the per-file progress hook, so a
27k-file walk costs ~600 `/proc` reads and not 27k (LOCKED A4).
`GET /api/sync/status` reports both ADDITIVELY as `rss_mb` /
`rss_peak_mb` (`int | null`; `null` is "no reading", never 0) and the
failed funnel samples too, because the run that died is the run whose
memory matters. That ONE sampler also owns the pair's invariant: the
kernel refreshes `ru_maxrss` lazily, so a raw peak can sit BELOW the
raw `VmRSS` (measured 164 vs 162) and `_sample_rss` lifts it to the
current reading — every surface (both lines, the payload, the page's
clause) therefore satisfies `peak >= current`, never a peak below the
run's own footprint, and an unknowable peak is never fabricated from
the current reading); UI-side,
honor the "never stale" feedback contract (PLAN §7.4). honor the "never stale" feedback contract (PLAN §7.4).
11. **Never block the event loop:** anything blocking (a `subprocess`, 11. **Never block the event loop:** anything blocking (a `subprocess`,
synchronous file/network I/O, a heavy walk) reached from an `async` synchronous file/network I/O, a heavy walk) reached from an `async`
+110 -1
View File
@@ -622,7 +622,9 @@ only) runs the whole source refresh in one click:
the sync area (`role="status"`, polite — phase 150 folded the the sync area (`role="status"`, polite — phase 150 folded the
pre-phase result-line announcer into the box). At settle the box pre-phase result-line announcer into the box). At settle the box
STAYS visible to carry the result line — the counts line STAYS visible to carry the result line — the counts line
("N added · …") after a success, "Sync cancelled" after a cancel — ("N added · …") after a success, "Sync cancelled" after a cancel, each
trailed by the run's peak memory (" · peak 1936 MB", phase 187; nothing
at all when the reading is null) —
and the button leaves the running state (the "Synced <stamp>" pill and the button leaves the running state (the "Synced <stamp>" pill
after a success — the date-qualified freshness stamp (phase 154): after a success — the date-qualified freshness stamp (phase 154):
`HH:MM` on the same local day, `M/D, h:MM AM/PM` otherwise `HH:MM` on the same local day, `M/D, h:MM AM/PM` otherwise
@@ -759,6 +761,32 @@ only) runs the whole source refresh in one click:
commit date. Fixing it means changing the quoting grammar — a date commit date. Fixing it means changing the quoting grammar — a date
change for every non-ASCII document already in a KB, so it is a future change for every non-ASCII document already in a KB, so it is a future
phase's call, not a transport phase's. phase's call, not a transport phase's.
- **The run's memory is a logged number and a line on the page**
(phase 187). Every sync lifecycle line ends with `rss_mb=` — and every
terminal with `rss_mb= rss_peak_mb=` — read through
`app/core/memory.py` (PROCESS RSS, never the host's cgroup figure,
which counts page cache; an unknowable reading prints `?`, never
`None`), and the SAME pair rides `GET /api/sync/status` as the
additive `rss_mb` / `rss_peak_mb` keys (`int | null`). The RAG page
(`/sources.html`) appends it to the result line it already spoke —
`3 added · 1 unchanged · peak 1936 MB`, `Sync cancelled · peak 1936 MB`
— no new control, and nothing at all when the pair is null. "How much
RAM did that sync use?" is therefore one grep of the app's own log
**or** one
glance at the page that started the run, instead of a dig through
`journalctl` unit-teardown lines from three boots (the 2026-10-15
incident). The fields are appended, so every older grep still matches,
and the sampler keeps the pair honest (`peak >= current` — a kernel
high-water refreshed lazily is raised to the current reading, because a
peak below the run's own footprint is a false number, not a smaller one).
Pinned by `tests/unit/test_core_memory.py` (every helper branch, the
never-raise contract), `tests/unit/test_sync_button.py` (the sampling
points, the 5 s throttle, the pair's invariant),
`tests/integration/test_sync_api.py` (the four lifecycle lines, the
additive keys, the terminals keeping the final pair, the anonymous 403)
and end to end in the browser by
`tests/e2e/test_sync_peak_memory_visible.py`. See *Operational notes:
the sync's memory (phase 187)*.
- **Cancel**: while a run is in flight, a **Cancel** button appears - **Cancel**: while a run is in flight, a **Cancel** button appears
next to the sync button; clicking it posts `POST /api/sync/cancel` next to the sync button; clicking it posts `POST /api/sync/cancel`
(admin only) and registers a cooperative cancel (`202`) — the run (admin only) and registers a cooperative cancel (`202`) — the run
@@ -1516,6 +1544,87 @@ under `.agents/phases/`.
> overriding it) is correct; unsetting it would hand every client > overriding it) is correct; unsetting it would hand every client
> uncompressed JSON. > uncompressed JSON.
### Operational notes: the sync's memory (phase 187)
The 2026-10-15 incident made "how much RAM did that sync use?" a dig
through `journalctl` unit-teardown lines from three boots — and the boot
that killed the box had no teardown record at all, because it never shut
down cleanly. Every sync lifecycle line now carries the number, so the
question is one grep:
```
2026-10-15 04:12:07 INFO app.api.sync :: sync: done detail={'files': 27411, …} rss_mb=412 rss_peak_mb=1936
```
```bash
grep ' :: ' server.log | grep ' rss_peak_mb=' # every sync terminal
grep ' :: ' server.log | grep 'sync: started ' # where each run began
grep ' :: ' server.log | grep 'sync: failed state=' # the failed ones, with the error
```
- **`rss_mb=`** is the process's CURRENT resident set size in MB (Linux:
`VmRSS` from `/proc/self/status`), read at the moment that line is
written — the run's start, the import's summary line, and every
terminal.
- **`rss_peak_mb=`** is the process HIGH-WATER mark
(`getrusage(RUSAGE_SELF).ru_maxrss`): the most the process ever held,
which is the number the incident was actually about. It appears on the
three terminals (`sync: done …`, `sync: cancelled by user …`,
`sync: failed state=failed …`) beside the final counts. The two numbers
come from different kernel counters and both floor to whole MB, and the
kernel refreshes the high-water **lazily**, so a raw `rss_peak_mb()` can
sit under a raw `rss_mb()` (measured: 164 against 162). The run's own
sampling point (`app.api.sync._sample_rss`) therefore lifts a lagging
peak to the current reading before anything publishes it — so on every
sync line, on `/api/sync/status` and on the page you always get
`peak >= current`. An unknowable peak (`?` / `null`) is never fabricated
from the current reading.
- **`?` means "unknowable here", never 0 and never `None`** —
`app/core/memory.py` never raises, so a platform without `/proc` (or
without `resource`) degrades the reading and the sync keeps running.
- **Process vs cgroup — know which number you are reading.** These are
PROCESS numbers. The host's `/sys/fs/cgroup/memory.peak` — and the
`systemd` "… memory peak" line the incident started from — is a
different, larger figure, because a cgroup counts **page cache** too:
that box's app cgroup showed ~2.16 GB of `file` against ~211 MB of
`anon` while idle. Neither figure is wrong; the log says which one it
is (LOCKED A1), and `app.core.memory.cgroup_peak_mb()` exists for an
operator who wants the other one for context.
- **Append-only** (LOCKED A2): the fields trail the existing ones on the
pre-phase lines (`sync: started repos=… origin=… git=… local=…`,
`sync: done detail=…`, `sync: cancelled by user state=cancelled
files_done=… files_total=…`, `import: summary files=… … formats=…`),
and the failed funnel keeps its `logger.exception` traceback while
gaining ONE sanitized one-line summary (`error=` carries exactly the
masked string the status field does). So every grep written before this
phase still matches.
- **On the page, not only in the log.** The same pair rides
`GET /api/sync/status` (admin-only, like every sync surface) as the
additive `rss_mb` / `rss_peak_mb` keys, and the RAG page
(`/sources.html`) appends them to the result line it already speaks —
`3 added · 1 unchanged · peak 1936 MB`, or
`Sync cancelled · peak 1936 MB`. A terminal KEEPS
the final reading next to the final counts, so a page reloaded an hour
later still shows what that run reached, and `sync: done …` logs the
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
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
clause at all — never `peak ? MB`, never `peak 0 MB`. The failed
terminal's box stays empty by the existing contract (the banner and
the modal carry the error, byte-identical); a failed run's numbers are
in its log line and in the status payload.
There is deliberately **no** memory audit script and **no** metrics
endpoint (LOCKED A3/A2 — no aux services, A12): the grep above is the
interface.
--- ---
## Configuration reference ## Configuration reference
+2
View File
@@ -1 +1,3 @@
# TODO # TODO
- [ ] Changing the App name in settings broke, it doesn't update the name now.
+197 -5
View File
@@ -239,6 +239,32 @@ it (a file with no images must not inherit the previous PDF's
position). ASSUMPTION A5: no new ``phase`` value — the image spans position). ASSUMPTION A5: no new ``phase`` value — the image spans
live inside ``phase: "import"``. live inside ``phase: "import"``.
Phase 187 (task 02) makes the run's MEMORY a logged number: every
lifecycle line gains trailing RSS fields read through
:mod:`app.core.memory` — ``sync: started … rss_mb=N`` and, in every
terminal, ``… rss_mb=N rss_peak_mb=M`` (the ``import: summary …`` line
carries ``rss_mb=`` too, on the importer side). APPEND-ONLY (LOCKED A2):
no existing field is renamed, reordered or re-typed, so every pre-phase
``grep`` keeps working; an unknowable reading renders ``rss_mb=?``
through the ONE :func:`app.core.memory.fmt_mb` helper. The numbers are
PROCESS numbers, not the host's cgroup figure — the
:mod:`app.core.memory` module docstring owns that caveat. The ``failed``
funnel keeps its ``logger.exception`` (the traceback) and gains ONE
sanitized one-line summary beside the status field, so the raw exception
string never reaches the log un-sanitized.
Phase 187 (task 03) puts the same numbers on the STATUS surface: the
status object carries ``rss_mb`` / ``rss_peak_mb`` (``int | null``,
sampled through the ONE :func:`_sample_rss` — at the run start, at every
``phase`` transition, in every terminal, and at most once per
``_RSS_SAMPLE_INTERVAL_S`` from the per-file progress hook, so a 27k-file
walk costs ~600 ``/proc`` reads and not 27k — LOCKED A4), and
``GET /api/sync/status`` reports both keys ADDITIVELY. The terminals keep
the FINAL reading next to the final counts (the phase-64 convention), so
the page that started the run answers the incident's question without a
shell. A reading the platform cannot know stays ``null`` on the wire (the
UI renders no clause for it) — never 0, never an invented number.
The ``failed`` state's ``error`` string is masked by the shared The ``failed`` state's ``error`` string is masked by the shared
sanitizer — the ``user:pass@`` masker now lives in :mod:`app.core.errors` sanitizer — the ``user:pass@`` masker now lives in :mod:`app.core.errors`
(imported here under the private name ``_sanitize_error``). (imported here under the private name ``_sanitize_error``).
@@ -247,6 +273,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@@ -257,6 +284,7 @@ from fastapi import APIRouter, Depends, HTTPException
from app.config import get_settings from app.config import get_settings
from app.core.auth import require_admin from app.core.auth import require_admin
from app.core.errors import sanitize_error as _sanitize_error from app.core.errors import sanitize_error as _sanitize_error
from app.core.memory import fmt_mb, rss_mb, rss_peak_mb
from app.db import SessionLocal from app.db import SessionLocal
from app.rag.folder_summaries import generate_folder_summaries, missing_folder_summaries from app.rag.folder_summaries import generate_folder_summaries, missing_folder_summaries
from app.rag.git_sources import clone_url_for, effective_sources from app.rag.git_sources import clone_url_for, effective_sources
@@ -338,6 +366,21 @@ class SyncStatus:
``progress`` hook resets it to 0/0 (a ``progress`` call names the ``progress`` hook resets it to 0/0 (a ``progress`` call names the
file and clears the image position; a file with no images must file and clears the image position; a file with no images must
not inherit the previous PDF's position). not inherit the previous PDF's position).
Phase 187 (task 03) memory fields: ``rss_mb`` is the process's
CURRENT resident set size in MB and ``rss_peak_mb`` its HIGH-WATER
mark — both PROCESS readings from :mod:`app.core.memory`, never the
host's cgroup figure (that one counts page cache; the caveat lives
in that module). They are written by ONE sampler, :func:`_sample_rss`:
forced at the run start, at every ``phase`` transition and in every
terminal, and throttled to one read per ``_RSS_SAMPLE_INTERVAL_S``
from the per-file ``progress`` hook (LOCKED A4 — never one ``/proc``
read per file). Both reset to ``None`` with the run — an idle or
never-run status reports null, and the Sources page renders no peak
clause for null — and every terminal KEEPS the final reading next to
the final counts (the phase-64 keep-final-values convention, the one
that makes "how much RAM did that sync use?" answerable from the
page after the run, not only from the log).
""" """
state: Literal["idle", "running", "success", "failed", "cancelled"] = "idle" state: Literal["idle", "running", "success", "failed", "cancelled"] = "idle"
@@ -372,6 +415,13 @@ class SyncStatus:
# the prelude/overview/summaries phases, and in terminal states. # the prelude/overview/summaries phases, and in terminal states.
images_done: int = 0 images_done: int = 0
images_total: int = 0 images_total: int = 0
# Phase 187 (task 03): the run's PROCESS memory in MB — current and
# high-water (app.core.memory, never the host's cgroup figure). None
# idle / never-run and reset with the run; kept as the FINAL reading
# in every terminal. Written ONLY through _sample_rss, the one
# sampling point (LOCKED A4).
rss_mb: int | None = None
rss_peak_mb: int | None = None
_status = SyncStatus() _status = SyncStatus()
@@ -383,6 +433,66 @@ _task: asyncio.Task[None] | None = None
# starts, so a stale event can never bleed into the next run. # starts, so a stale event can never bleed into the next run.
_cancel_event = asyncio.Event() _cancel_event = asyncio.Event()
#: Phase 187 (task 03, D3/A4): how often the PER-FILE progress hook may
#: re-read the process memory, in seconds. That hook fires once per
#: file, so without this guard a 27k-file walk would cost 27k
#: ``/proc/self/status`` reads; with it the same walk costs ~600 and the
#: operator watching the page still sees the number move (PLAN §7.4's
#: never-stale contract). Every other sampling point (the run start, the
#: ``phase`` transitions, the terminals) FORCES the read instead.
_RSS_SAMPLE_INTERVAL_S = 5.0
#: ``time.monotonic()`` of the last RSS read; ``-inf`` means "never", so
#: the first throttled call reads instead of waiting out the interval.
#: Module-global like ``_status`` itself (the tests' fresh-state fixture
#: re-seeds both).
_rss_sampled_at: float = float("-inf")
def _sample_rss(force: bool = False) -> None:
"""Read the process memory onto ``_status`` — the ONE sampling point
(phase 187, task 03).
``force=True`` reads NOW: the run start, each ``phase`` transition,
every terminal — the points where the number is worth a syscall and
where a stale reading would be a lie. ``force=False`` (the per-file
progress hook) is a NO-OP inside :data:`_RSS_SAMPLE_INTERVAL_S` of
the previous read, which is what makes the sampling throttled rather
than per-file (LOCKED A4).
Both fields come off the SAME read, so the pair the status endpoint
and the lifecycle line report is one instant, not two. Nothing here
raises: :mod:`app.core.memory` degrades a platform that cannot know
to ``None``, which the status renders as JSON null (no UI clause) and
the log as ``?``.
**The pair is normalized to one truth: ``peak >= current``.** The two
numbers come from two different kernel counters read at two instants
(``VmRSS`` from ``/proc/self/status``, then ``ru_maxrss``), and the
kernel refreshes its high-water LAZILY, so a growing process really
can report a current reading ABOVE the reported high-water (measured
on this repo's own suite: ``rss_mb=164`` against ``rss_peak_mb=162``).
A "peak" below the run's own current footprint is not a smaller
number, it is a FALSE one — the high-water is by definition at least
the current set — so the ONE sampling point raises it to the current
reading. That is a lower bound on the truth, never an invention, and
it is why every consumer (both lifecycle lines, the status payload,
the page's clause) can now take ``peak >= current`` as an invariant
instead of carrying a slack. A ``None`` is still a ``None``: an
unknowable peak is never fabricated from the current reading.
"""
global _rss_sampled_at
now = time.monotonic()
if not force and (now - _rss_sampled_at) < _RSS_SAMPLE_INTERVAL_S:
return
_rss_sampled_at = now
current = rss_mb()
peak = rss_peak_mb()
if current is not None and peak is not None and peak < current:
peak = current # a high-water below the current set is a stale one
_status.rss_mb = current
_status.rss_peak_mb = peak
@router.get("/status") @router.get("/status")
def sync_status() -> dict[str, Any]: def sync_status() -> dict[str, Any]:
@@ -415,8 +525,22 @@ def sync_status() -> dict[str, Any]:
file share ONE continuous counter): 0/0 before the first image, in file share ONE continuous counter): 0/0 before the first image, in
the prelude/overview/summaries phases, and in terminal states; the the prelude/overview/summaries phases, and in terminal states; the
per-file ``progress`` hook clears it at every file boundary (a per-file ``progress`` hook clears it at every file boundary (a
file with no images never shows a stale position). Additive only — file with no images never shows a stale position).
every pre-existing key keeps its name, type, and meaning.
Phase 187 (task 03): ``rss_mb`` / ``rss_peak_mb`` are the run's
PROCESS memory in MB — the current resident set and the high-water
mark (:mod:`app.core.memory`, never the host's cgroup figure, which
counts page cache). ``null`` means "no reading", which is the idle /
never-run state AND any platform that cannot know — never 0, and
never an invented number. The run samples them through
:func:`_sample_rss`: at the run start, at every ``phase``
transition, throttled to one read per 5 s through the import's
per-file hook, and FORCED in every terminal — so a terminal reports
the run's FINAL reading next to its final counts (the phase-64
convention, same as ``files_done`` / ``files_total``), and a reload
after the run still shows the number that ``sync: done …`` logged.
Additive only — every pre-existing key keeps its name, type, and
meaning.
""" """
return { return {
"state": _status.state, "state": _status.state,
@@ -449,6 +573,14 @@ def sync_status() -> dict[str, Any]:
"current_summary": _status.current_summary, "current_summary": _status.current_summary,
"summaries_done": _status.summaries_done, "summaries_done": _status.summaries_done,
"summaries_total": _status.summaries_total, "summaries_total": _status.summaries_total,
# Phase 187 (task 03): the run's PROCESS memory in MB (current +
# high-water), read through the ONE _sample_rss. null idle /
# never-run; in a terminal it is the run's FINAL reading (the
# phase-64 keep-final-values convention — the number the
# 2026-10-15 incident had to reconstruct from systemd teardown
# lines, now on the page that started the run).
"rss_mb": _status.rss_mb,
"rss_peak_mb": _status.rss_peak_mb,
} }
@@ -567,6 +699,12 @@ async def _run_sync() -> None:
# per-file progress hook re-clears them at every file boundary). # per-file progress hook re-clears them at every file boundary).
_status.images_done = 0 _status.images_done = 0
_status.images_total = 0 _status.images_total = 0
# Phase 187 (task 03): the run-start memory sample — forced, so it
# BOTH replaces the previous run's readings (a forced sample fully
# determines the pair; None where the platform cannot know) and
# re-arms the 5 s throttle. No stale peak ever bleeds into a new
# run's status.
_sample_rss(force=True)
try: try:
settings = get_settings() settings = get_settings()
# Step 1 (phase 41): fail fast — verify both models the sync # Step 1 (phase 41): fail fast — verify both models the sync
@@ -591,9 +729,15 @@ async def _run_sync() -> None:
# legacy directories. # legacy directories.
raise GitSyncError("no sources configured (git or local)") raise GitSyncError("no sources configured (git or local)")
git_count = sum(1 for row in rows if row.kind == "git") git_count = sum(1 for row in rows if row.kind == "git")
# Phase 187 (task 02): the trailing rss_mb= is APPEND-ONLY (LOCKED
# A2) — every pre-phase grep of this line keeps matching. Task 03
# logs the run-start SAMPLE (the reading the status reports) so
# the line and the payload never disagree — the shape is
# byte-identical to task 02's.
logger.info( logger.info(
"sync: started repos=%d origin=%s git=%d local=%d", "sync: started repos=%d origin=%s git=%d local=%d rss_mb=%s",
len(rows), origin, git_count, len(rows) - git_count, len(rows), origin, git_count, len(rows) - git_count,
fmt_mb(_status.rss_mb),
) )
sources_root = Path(settings.sources_dir).expanduser() sources_root = Path(settings.sources_dir).expanduser()
sources: list[Path] = [] sources: list[Path] = []
@@ -690,6 +834,11 @@ async def _run_sync() -> None:
# run-start reset alone cannot cover it). # run-start reset alone cannot cover it).
_status.images_done = 0 _status.images_done = 0
_status.images_total = 0 _status.images_total = 0
# Phase 187 (task 03): the run's memory, sampled THROTTLED
# (unforced) — this hook fires once per file, so a 27k-file
# walk reads /proc ~600 times instead of 27k (LOCKED A4)
# while the number on the page still climbs (PLAN §7.4).
_sample_rss()
# Phase 129 (task 03): the in-flight file's embed-chunk # Phase 129 (task 03): the in-flight file's embed-chunk
# position — the LLMClient.embed hook (task 01) fires before # position — the LLMClient.embed hook (task 01) fires before
@@ -720,6 +869,10 @@ async def _run_sync() -> None:
# starts NOW (the model-check + clone/pull prelude above # starts NOW (the model-check + clone/pull prelude above
# reported ``phase: null``). # reported ``phase: null``).
_status.phase = "import" _status.phase = "import"
# Phase 187 (task 03): the phase transitions are the forced
# sampling points — each stage of the run begins with its own
# reading on the status (the per-file hook throttles).
_sample_rss(force=True)
# Phase 134 (task 01): the import boundary — a cancel between # Phase 134 (task 01): the import boundary — a cancel between
# the source walk and the import (or parked in a blocking git # the source walk and the import (or parked in a blocking git
# span that just finished) lands here, before any file is # span that just finished) lands here, before any file is
@@ -744,6 +897,7 @@ async def _run_sync() -> None:
overview = False overview = False
if summary.added + summary.updated > 0: if summary.added + summary.updated > 0:
_status.phase = "overview" # phase 98 (task 01) _status.phase = "overview" # phase 98 (task 01)
_sample_rss(force=True) # phase 187 (task 03)
# Phase 129 (task 03): the import phase ends here — the # Phase 129 (task 03): the import phase ends here — the
# chunk position (a sub-position of a done file) clears # chunk position (a sub-position of a done file) clears
# with the phase change; the file counts keep their final # with the phase change; the file counts keep their final
@@ -803,6 +957,7 @@ async def _run_sync() -> None:
# position through the (long) span (its total is the # position through the (long) span (its total is the
# affected count). # affected count).
_status.phase = "summaries" _status.phase = "summaries"
_sample_rss(force=True) # phase 187 (task 03)
# Phase 129 (task 03): the import phase ends here too # Phase 129 (task 03): the import phase ends here too
# (the changed-KB branch skips no phase) — same # (the changed-KB branch skips no phase) — same
# chunk-position clear as the overview transition. # chunk-position clear as the overview transition.
@@ -828,6 +983,7 @@ async def _run_sync() -> None:
# phase + hook (the fill's total is the missing # phase + hook (the fill's total is the missing
# count — the hook's loop-start ``total``). # count — the hook's loop-start ``total``).
_status.phase = "summaries" _status.phase = "summaries"
_sample_rss(force=True) # phase 187 (task 03)
# Phase 129 (task 03): the import phase ends here # Phase 129 (task 03): the import phase ends here
# too (the unchanged-walk gap-fill branch) — same # too (the unchanged-walk gap-fill branch) — same
# chunk-position clear. # chunk-position clear.
@@ -903,7 +1059,20 @@ async def _run_sync() -> None:
"overview": overview, "overview": overview,
"sources_version": sources_version, "sources_version": sources_version,
} }
logger.info("sync: done detail=%s", _status.detail) # Phase 187 (task 02): the memory the run is at NOW and its
# high-water mark, next to the final counts (the phase-64
# keep-final-values convention, extended to the numbers the
# 2026-10-15 incident had to reconstruct from systemd teardown
# lines). Appended after ``detail=`` — the line stays greppable
# as ``sync: done detail=``. Task 03 forces the sample FIRST and
# logs it, so the payload's rss_mb / rss_peak_mb and this line
# carry the SAME final pair (the phase-64 convention, kept
# honest across both surfaces).
_sample_rss(force=True)
logger.info(
"sync: done detail=%s rss_mb=%s rss_peak_mb=%s",
_status.detail, fmt_mb(_status.rss_mb), fmt_mb(_status.rss_peak_mb),
)
except asyncio.CancelledError: except asyncio.CancelledError:
# Phase 134 (task 01): the TWO conversion paths (the module # Phase 134 (task 01): the TWO conversion paths (the module
# docstring's cancel paragraph). A user-requested cancel — # docstring's cancel paragraph). A user-requested cancel —
@@ -933,9 +1102,17 @@ async def _run_sync() -> None:
# counts above keep their final values). # counts above keep their final values).
_status.images_done = 0 _status.images_done = 0
_status.images_total = 0 _status.images_total = 0
# Phase 187 (task 02): the same two trailing fields as every
# other terminal — a cancelled run's memory is still data.
# Task 03 forces the sample first, so the cancelled payload
# keeps that same final pair (the status object stays
# authoritative after a cancel, as everywhere).
_sample_rss(force=True)
logger.info( logger.info(
"sync: cancelled by user state=cancelled files_done=%d files_total=%d", "sync: cancelled by user state=cancelled files_done=%d files_total=%d"
" rss_mb=%s rss_peak_mb=%s",
_status.files_done, _status.files_total, _status.files_done, _status.files_total,
fmt_mb(_status.rss_mb), fmt_mb(_status.rss_peak_mb),
) )
raise raise
except Exception as e: # noqa: BLE001 — a background task dies in state, see above except Exception as e: # noqa: BLE001 — a background task dies in state, see above
@@ -943,6 +1120,21 @@ async def _run_sync() -> None:
_status.state = "failed" _status.state = "failed"
_status.finished_at = datetime.now(UTC) _status.finished_at = datetime.now(UTC)
_status.error = _sanitize_error(str(e)) _status.error = _sanitize_error(str(e))
# Phase 187 (task 02): ONE sanitized one-line summary beside the
# status field (the SAME masked string, so the log can never leak
# credentials the API does not) — the ``logger.exception`` above
# stays for the traceback. Task 03 forces the sample first, so
# the failed payload carries the same final pair as this line —
# a run that died mid-walk is exactly the run whose memory
# matters. _sample_rss never raises (app.core.memory never
# raises), so a memory read can never mask the failure.
_sample_rss(force=True)
logger.error(
"sync: failed state=failed files_done=%d files_total=%d"
" rss_mb=%s rss_peak_mb=%s error=%s",
_status.files_done, _status.files_total,
fmt_mb(_status.rss_mb), fmt_mb(_status.rss_peak_mb), _status.error,
)
_status.current_file = None # phase 64: keep the final counts _status.current_file = None # phase 64: keep the final counts
_status.phase = None # phase 98: keep the final summary counts _status.phase = None # phase 98: keep the final summary counts
_status.current_summary = None _status.current_summary = None
+155
View File
@@ -0,0 +1,155 @@
"""Process memory as a number — current RSS, peak RSS, and a log formatter
(phase 187, task 01, D1, LOCKED A1).
**Pure stdlib:** this module imports the standard library only — no FastAPI,
no ``app.api``, no third-party package — mirroring ``app/core/response_budget.py``.
**The incident (owner, 2026-10-15).** Reconstructing "how much RAM did the
sync actually use?" meant reading ``systemd`` unit-teardown lines from three
boots (``brain-of-code-app.service: … 3.3G memory peak``), and boot −1 — the
run that killed the box — had no teardown record at all because the VM never
shut down cleanly. The app itself had no opinion about its own memory: no
log line, no status field, nothing to ``grep``. These helpers give the sync's
lifecycle lines (task 02) a number so the next incident starts from data
instead of archaeology.
**The helpers.**
* :func:`rss_mb` — the process's CURRENT resident set size in MB. Linux:
parse ``VmRSS`` from ``/proc/self/status`` (kB, floored to whole MB). On a
platform without ``/proc`` — or on any failure reading it — fall back to
``resource.getrusage(RUSAGE_SELF).ru_maxrss // 1024``, which is the
process HIGH-WATER mark, not a current value: the best available proxy.
(``ru_maxrss`` is kilobytes on Linux, the deploy target.)
* :func:`rss_peak_mb` — ``ru_maxrss // 1024``, the process high-water mark.
``None`` only where the ``resource`` module is unavailable (Windows); the
import is guarded so this module still loads there.
* :func:`cgroup_peak_mb` — ``/sys/fs/cgroup/memory.peak`` (cgroup v2, bytes,
floored to MB) when that file exists and parses, else ``None``. Read it
for context, never as the app's number — see the caveat below.
* :func:`fmt_mb` — ``"?"`` for ``None``, else ``str(v)``, so a log line
renders ``rss_mb=?`` and never ``rss_mb=None`` (task 02 single-sources
the rendering through this one function).
**The never-raise contract.** Every helper returns ``int`` or ``None``;
none may propagate an exception to a caller on the sync or request path.
Memory is diagnostics — a failed ``/proc`` read must never fail the turn,
the sync, or a status poll that asked for a number, so every read is
wrapped and every failure mode degrades to ``None`` (or to the proxy).
**The container/cgroup caveat (written ONCE, here, so no future reader
conflates the numbers).** Inside a container ``/sys/fs/cgroup/memory.peak``
is usually absent (the HOST reads it), and the host's number counts PAGE
CACHE too: the incident box's app cgroup showed ~2.16 GB of ``file``
against ~211 MB of ``anon`` while idle, and ``systemd`` reported
``3.3G memory peak`` for the unit while the uvicorn process was a fraction
of that. This module therefore reports PROCESS numbers (LOCKED A1); an
operator dashboard may legitimately show a larger cgroup number — neither
is wrong, and the log says which one it is.
"""
from __future__ import annotations
import contextlib
from types import ModuleType
#: Procfs file carrying ``VmRSS`` (current RSS in kB) among the other
#: ``/proc/self`` identity fields. Module-level so tests can point it at a
#: stub file (or a nonexistent path) without patching ``open`` globally.
PROC_STATUS_PATH = "/proc/self/status"
#: cgroup v2 high-water mark, in BYTES. Usually absent inside a container —
#: see the module docstring's caveat; ``None`` is the normal answer there.
CGROUP_PEAK_PATH = "/sys/fs/cgroup/memory.peak"
#: ``resource`` is Unix-only. Guarded so the module loads (and every helper
#: answers ``None`` where it cannot know) on platforms without it.
_resource: ModuleType | None
try:
import resource as _resource
except ImportError:
_resource = None
#: The ``/proc/self/status`` label holding current RSS, in kB.
_VM_RSS_LABEL = "VmRSS:"
def rss_mb() -> int | None:
"""Current process RSS in whole MB, or ``None`` if unknowable.
Linux path: the ``VmRSS:`` line of ``/proc/self/status`` ("kB" is
implied by the kernel; the value is floored with integer division).
Any failure — no ``/proc`` (non-Linux), unreadable file, a file
without the line, a line whose value is not an integer — falls back
to :func:`rss_peak_mb`, the high-water proxy. ``KeyError`` is caught
defensively per the phase contract; the line-scanning form below
itself cannot raise it (the absent-line case is a fall-through).
"""
with contextlib.suppress(OSError, KeyError, ValueError, IndexError), open(
PROC_STATUS_PATH, encoding="utf-8"
) as fh:
for line in fh:
if line.startswith(_VM_RSS_LABEL):
return int(line.split()[1]) // 1024
return rss_peak_mb()
def rss_peak_mb() -> int | None:
"""Process high-water RSS in whole MB, or ``None`` without ``resource``.
``ru_maxrss`` is the high-water mark, never a current value (kilobytes
on Linux, the deploy target), floored to whole MB. The ``getrusage``
call itself is wrapped to honor the never-raise contract even though
``RUSAGE_SELF`` cannot realistically fail.
**One honest edge, measured here (so nobody "fixes" this helper):** the
kernel refreshes its high-water LAZILY and the value FLOORs to whole
MB, so a growing process can report a CURRENT ``VmRSS`` ABOVE its
reported ``ru_maxrss`` — observed on this repo's own suite at
``VmRSS`` 217,288 kB against ``ru_maxrss`` 216,832 kB, and again as
``rss_mb=164`` against ``rss_peak_mb=162``. This helper stays the RAW
kernel reading (it is a diagnostic, and a diagnostic that quietly
edits the OS is worse than the imprecision).
The CALLER that publishes the pair is what normalizes it:
:func:`app.api.sync._sample_rss` — the single sampling point behind
both sync lifecycle lines, ``GET /api/sync/status`` and the Sources
page's clause — lifts a peak that sits below the current reading up to
that reading, because a high-water BELOW the process's own current set
is not a smaller number but a false one. So the RUN's numbers carry
``peak >= current``, while a raw ``rss_peak_mb()`` call may still sit
under a raw ``rss_mb()``.
"""
if _resource is None:
return None
try:
return int(_resource.getrusage(_resource.RUSAGE_SELF).ru_maxrss) // 1024
except (OSError, AttributeError, TypeError, ValueError):
return None
def cgroup_peak_mb() -> int | None:
"""The cgroup v2 ``memory.peak`` in whole MB, or ``None``.
Context for operators, NOT the app's number: inside a container this
file is usually absent (the host reads it), and the host's number
counts PAGE CACHE too (~2.16 GB ``file`` against ~211 MB ``anon`` on
the idle incident box; ``systemd`` said ``3.3G memory peak`` for the
unit while the uvicorn process was a fraction of that). PROCESS
numbers are what the sync logs — module docstring has the full
caveat, LOCKED A1.
"""
try:
with open(CGROUP_PEAK_PATH, encoding="utf-8") as fh:
return int(fh.read().strip()) // (1024 * 1024)
except (OSError, ValueError):
return None
def fmt_mb(value: int | None) -> str:
"""Render an MB reading for a log line: ``?`` when unknown, digits when not.
``0`` is a VALUE, not missing — it renders ``"0"``. This is the single
place the ``rss_mb=?`` convention lives (task 02 formats every line
through here).
"""
return "?" if value is None else str(value)
+10 -1
View File
@@ -357,6 +357,7 @@ from sqlalchemy import Select, select
from sqlalchemy.orm import Session, defer, load_only from sqlalchemy.orm import Session, defer, load_only
from app.config import Settings from app.config import Settings
from app.core.memory import fmt_mb, rss_mb
from app.db import SessionLocal from app.db import SessionLocal
from app.models import Chunk, Document from app.models import Chunk, Document
from app.rag.chunker import chunk_document, extract_title from app.rag.chunker import chunk_document, extract_title
@@ -532,7 +533,7 @@ class ImportSummary:
"summary_backfilled=%d dates_updated=%d images_failed=%d svg_indexed=%d " "summary_backfilled=%d dates_updated=%d images_failed=%d svg_indexed=%d "
"image_context_backfilled=%d pdfs_screenshotted=%d " "image_context_backfilled=%d pdfs_screenshotted=%d "
"pdfs_pictorial=%d pdf_pictorial_pages=%d pdf_images_deduped=%d " "pdfs_pictorial=%d pdf_pictorial_pages=%d pdf_images_deduped=%d "
"pdf_images_blank_skipped=%d formats=%s", "pdf_images_blank_skipped=%d formats=%s rss_mb=%s",
self.files, self.files,
self.added, self.added,
self.updated, self.updated,
@@ -554,6 +555,14 @@ class ImportSummary:
self.pdf_images_deduped, self.pdf_images_deduped,
self.pdf_images_blank_skipped, self.pdf_images_blank_skipped,
self.format_counts(), self.format_counts(),
# Phase 187 (task 02): the run's CURRENT process RSS on the
# cron-safe summary line (PLAN §9) — appended, so the
# pre-phase line stays greppable field-for-field. The
# high-water mark belongs to the SYNC TERMINALS
# (app/api/sync.py), not to this line; ``format_counts()``
# is deliberately untouched, so every other consumer of it
# is byte-identical.
fmt_mb(rss_mb()),
) )
+55 -3
View File
@@ -457,6 +457,21 @@
* rules stop walking a full tree — folderKnownIn() only claims what * rules stop walking a full tree — folderKnownIn() only claims what
* the HELD levels prove — and treeStats() prefers kbTree.totals (the * the HELD levels prove — and treeStats() prefers kbTree.totals (the
* walk is the pre-phase-167 fallback only). * walk is the pre-phase-167 fallback only).
*
* Phase 187 (task 03, D4/A4) — the sync result line carries the run's
* PEAK MEMORY. The two additive `rss_mb` / `rss_peak_mb` keys on
* GET /api/sync/status feed ONE clause builder (fmtSyncPeakClause),
* which APPENDS ` · peak 512 MB` to the result text the settle already
* wrote (the fmtSyncResult counts line, "Sync cancelled") — no new
* control, no new state string, no new live region, and the failed
* terminal's empty box is untouched (the banner + modal keep the error
* copy byte-identical, phase 150). Null — idle, a run that predates the
* phase, a platform that cannot know, and the upload job (whose status
* has no memory keys at all) — renders NOTHING, never "peak ? MB". The
* clause rides the single box writer, so the sole aria-live region
* announces today's wording plus the clause, and the number is the
* PROCESS high-water mark app/core/memory.py reports (never the host's
* cgroup figure — that one counts page cache).
*/ */
import { fetchIsAdmin } from "./header.js"; import { fetchIsAdmin } from "./header.js";
@@ -1103,6 +1118,37 @@ export async function mount(root) {
return { main, count, chunk, image }; return { main, count, chunk, image };
} }
/* Phase 187 (task 03, D4/A4): the run's PEAK-memory clause — the one
* number the 2026-10-15 incident had to reconstruct from three boots
* of systemd teardown lines, now on the page that started the run.
* It is a CLAUSE on the result line that already exists (the
* fmtSyncResult counts line, "Sync cancelled"), never a new control,
* never a state string of its own, and never a replacement: the
* existing copy keeps its exact wording and the clause trails it
* (" · peak 512 MB"). The value is the status's rss_peak_mb (the
* process high-water mark, the number the incident was about) or,
* where only a current reading exists, rss_mb — both PROCESS numbers
* from app/core/memory.py, never the host's cgroup figure (that one
* counts page cache; the caveat owns app/core/memory.py).
* NULL IS THE ABSENCE CONTRACT: both keys null (idle, a run predating
* this phase, a platform that cannot know) renders NOTHING — never
* "peak ? MB", never "peak null MB", never 0. The clause rides the
* SAME string the single box writer (setSyncProgress) lands on
* #sync-progress-text, so the box — the sole sync text surface AND
* the sole aria-live region (phase 150) — announces today's wording
* plus the clause, with no second announcement and no new region. It
* appears in the TERMINAL renders only (success + cancelled): the
* running label keeps its byte-identical shape, the failed terminal's
* box stays empty by the phase-150 contract (the banner + modal carry
* the error, byte-identical), and the upload settle never has the keys
* at all — which is the null case again. Text wraps inside the
* existing line (no clip, no .cell-clip, no layout shift at 375px). */
function fmtSyncPeakClause(status) {
const peak = status ? status.rss_peak_mb ?? status.rss_mb : null;
if (typeof peak !== "number" || !Number.isFinite(peak) || peak < 0) return "";
return ` · peak ${peak} MB`;
}
function fmtSyncResult(detail) { function fmtSyncResult(detail) {
const d = detail || {}; const d = detail || {};
const added = d.added || 0; const added = d.added || 0;
@@ -1284,8 +1330,11 @@ export async function mount(root) {
// Phase 150 (task 02): the settle's counts line IS the box's // Phase 150 (task 02): the settle's counts line IS the box's
// result line (the sole live region) — the string builder is // result line (the sole live region) — the string builder is
// unchanged (A2: the "Synced <stamp>" label stays on the button; // unchanged (A2: the "Synced <stamp>" label stays on the button;
// the stamp is date-qualified, phase 154). // the stamp is date-qualified, phase 154). Phase 187 (task 03)
setSyncProgress(fmtSyncResult(status.detail)); // APPENDS the peak-memory clause to that same string — the counts
// line keeps its exact wording, the clause trails it, and a null
// peak contributes nothing (fmtSyncPeakClause).
setSyncProgress(fmtSyncResult(status.detail) + fmtSyncPeakClause(status));
hideSyncError(); hideSyncError();
emitSyncStatus(status); emitSyncStatus(status);
// Refresh the catalog live — the KB just changed. // Refresh the catalog live — the KB just changed.
@@ -1322,7 +1371,10 @@ export async function mount(root) {
* else. */ * else. */
function applySyncCancelled(status) { function applySyncCancelled(status) {
settleSyncButton("Sync sources"); settleSyncButton("Sync sources");
setSyncProgress("Sync cancelled"); // the box's result line (phase 150) // The box's result line (phase 150) + the phase-187 peak clause —
// a cancelled run's memory is still the number the operator asked
// for ("Sync cancelled · peak 512 MB", nothing when it is null).
setSyncProgress("Sync cancelled" + fmtSyncPeakClause(status));
hideSyncError(); hideSyncError();
emitSyncStatus(status); emitSyncStatus(status);
} }
+61
View File
@@ -0,0 +1,61 @@
"""Shared sync-result-line helpers (phase 187, task 03).
The Sources page's sync result line — the ``fmtSyncResult`` counts line
and the neutral ``"Sync cancelled"`` line the ``#sync-progress`` box
speaks — now trails the run's PEAK-MEMORY clause (`` · peak 1936 MB``),
sourced from ``GET /api/sync/status``'s additive ``rss_peak_mb`` /
``rss_mb`` pair (the 2026-10-15 incident: "how much RAM did that sync
use?" used to mean reading ``systemd`` unit-teardown lines off three
boots; now it is on the page that started the run and in the log).
The clause is per-run DATA, so the suites that pin the OWNER-VISIBLE
counts pin the counts plus an OPTIONAL clause:
* :func:`counts_line` — a Playwright-ready pattern: the exact expected
line, with `` · peak <N> MB`` allowed at the end (and the whole line
anchored, so a mutated counts line still fails);
* :func:`strip_peak_clause` — for the suites that compare two runs' or
two apps' lines byte-for-byte: the memory number belongs to the
process, not to the KB, so it is not part of a byte-identity contract.
A NULL pair renders NOTHING (``fmtSyncPeakClause``), which is why the
clause is optional rather than required: an idle app, a status that
predates phase 187, and the upload job all settle without it.
"""
from __future__ import annotations
import re
#: The clause sources.js appends (``fmtSyncPeakClause``), as a regex —
#: optional, because a null peak/current pair appends the empty string.
PEAK_CLAUSE = r"(?: · peak \d+ MB)?"
#: The same clause, for a substitution (``strip_peak_clause``): anchored
#: at end of line, so nothing else is ever stripped.
_PEAK_CLAUSE_TAIL = re.compile(r" · peak \d+ MB$")
def counts_line(expected: str | re.Pattern[str]) -> re.Pattern[str]:
"""Match the EXACT ``expected`` sync result line, clause included or
not — the pin a suite writes when it cares about the counts, not
about the number of megabytes the run happened to reach.
``expected`` is either the literal line (escaped, so the ``·`` and
the digits mean exactly what they say) or a suite's own shape
pattern (its anchors are dropped and re-applied around the optional
clause, so ``^3 added$`` becomes ``^3 added(?: · peak \\d+ MB)?$``).
"""
if isinstance(expected, re.Pattern):
body = expected.pattern
if body.startswith("^"):
body = body[1:]
if body.endswith("$"):
body = body[:-1]
return re.compile(rf"^{body}{PEAK_CLAUSE}$")
return re.compile(rf"^{re.escape(expected)}{PEAK_CLAUSE}$")
def strip_peak_clause(text: str) -> str:
"""``text`` without its trailing peak clause (if any) — the
byte-identity comparison's view of the line."""
return _PEAK_CLAUSE_TAIL.sub("", text)
+2 -1
View File
@@ -90,6 +90,7 @@ from app.models import Document
from e2e.auth_helpers import login from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
GIT_SOURCES_URL = "/git-sources.html" GIT_SOURCES_URL = "/git-sources.html"
@@ -381,7 +382,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
expect(page.locator("#sync-progress-text")).to_have_text(expected_result) expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
def _seed_image_doc( def _seed_image_doc(
+2 -1
View File
@@ -102,6 +102,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -447,7 +448,7 @@ def _expect_counts_line(page: Page, expected: str) -> None:
"""The counts line — the owner-visible sync report on the single sync """The counts line — the owner-visible sync report on the single sync
text surface (phase 150; sources.js fmtSyncResult; the zero "updated" text surface (phase 150; sources.js fmtSyncResult; the zero "updated"
part is omitted).""" part is omitted)."""
expect(page.locator("#sync-progress-text")).to_have_text(expected, timeout=15_000) expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected), timeout=15_000)
def _goto_sources(page: Page, base_url: str) -> None: def _goto_sources(page: Page, base_url: str) -> None:
+4 -3
View File
@@ -104,6 +104,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -483,7 +484,7 @@ def test_sync_survives_a_force_push(
assert body["state"] == "success", body assert body["state"] == "success", body
# The fixture doc (one A9-format file) is the only change — the # The fixture doc (one A9-format file) is the only change — the
# counts line rides the box (phase 150). # counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("1 added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("1 added"))
# The REAL clone was imported: the fixture path is in the Sources # The REAL clone was imported: the fixture path is in the Sources
# table (the drill-down tree, the test_sync_button pattern). # table (the drill-down tree, the test_sync_button pattern).
_expect_fixture_row(page) _expect_fixture_row(page)
@@ -511,7 +512,7 @@ def test_sync_survives_a_force_push(
# …and the digest delta re-indexed the file as updated (the counts # …and the digest delta re-indexed the file as updated (the counts
# are the secondary signal — the content assertion below is # are the secondary signal — the content assertion below is
# primary). The counts line rides the box (phase 150). # primary). The counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 updated") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 1 updated"))
# The KB holds the NEW head: the document modal serves the V2 # The KB holds the NEW head: the document modal serves the V2
# sentinel — the reset's content flowed through the import. # sentinel — the reset's content flowed through the import.
_expect_fixture_row(page) _expect_fixture_row(page)
@@ -533,7 +534,7 @@ def test_sync_survives_a_force_push(
# "1 unchanged" means the content hash did not move — drift back # "1 unchanged" means the content hash did not move — drift back
# to V1 would have re-reported as "1 updated" (the no-drift pin). # to V1 would have re-reported as "1 updated" (the no-drift pin).
# The counts line rides the box (phase 150). # The counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 unchanged") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 1 unchanged"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -1
View File
@@ -137,6 +137,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
from scripts.git_sync import file_commit_dates from scripts.git_sync import file_commit_dates
from scripts.import_docs import repo_name from scripts.import_docs import repo_name
@@ -488,7 +489,7 @@ def _run_sync(page: Page, expected_line: str | re.Pattern[str]) -> dict[str, Any
expect(btn).to_be_enabled() # never stale at the terminal state expect(btn).to_be_enabled() # never stale at the terminal state
expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-error-banner")).to_be_hidden()
expect(page.locator("#sync-progress-text")).to_have_text( expect(page.locator("#sync-progress-text")).to_have_text(
expected_line, timeout=UI_SETTLE_TIMEOUT_MS counts_line(expected_line), timeout=UI_SETTLE_TIMEOUT_MS
) )
return dict(body["detail"] or {}) return dict(body["detail"] or {})
+5 -4
View File
@@ -103,6 +103,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -515,7 +516,7 @@ def test_add_with_branch_syncs_that_branch(
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
# Both branch docs are fresh after the per-test truncate — the # Both branch docs are fresh after the per-test truncate — the
# counts line rides the box (phase 150). # counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("2 added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("2 added"))
# --- the branch's content is what got indexed ---------------------- # --- the branch's content is what got indexed ----------------------
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1) expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
@@ -539,7 +540,7 @@ def test_add_with_branch_syncs_that_branch(
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
# Nothing re-embeds (sha256 delta) — the no-op run announces the # Nothing re-embeds (sha256 delta) — the no-op run announces the
# unchanged count (release's two files) on the box (phase 150). # unchanged count (release's two files) on the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 2 unchanged") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 2 unchanged"))
# The branch's doc survived the prune re-import. # The branch's doc survived the prune re-import.
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1) expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
@@ -577,7 +578,7 @@ def test_default_branch_source_unaffected(
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
# The default branch's two docs are fresh — the counts line rides # The default branch's two docs are fresh — the counts line rides
# the box (phase 150). # the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("2 added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("2 added"))
# The default branch (main) is what landed — the pre-phase # The default branch (main) is what landed — the pre-phase
# byte-identical guarantee, observable through the UI… # byte-identical guarantee, observable through the UI…
@@ -684,7 +685,7 @@ def test_row_editor_branch_change_fails_loud(
# The KB is still empty (the failed sync aborted before the # The KB is still empty (the failed sync aborted before the
# import) — the restored branch's two docs are fresh additions; # import) — the restored branch's two docs are fresh additions;
# the counts line rides the box (phase 150). # the counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("2 added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("2 added"))
# And the branch's doc is what is indexed again. # And the branch's doc is what is indexed again.
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1) expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
+2 -1
View File
@@ -99,6 +99,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -330,7 +331,7 @@ def _sync_in_app(page: Page, app_url: str) -> None:
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
# Both fixture docs are fresh after the per-test truncate — the # Both fixture docs are fresh after the per-test truncate — the
# counts line rides the box (phase 150). # counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("2 added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("2 added"))
def _source_node(tree_json: dict[str, Any]) -> dict[str, Any]: def _source_node(tree_json: dict[str, Any]) -> dict[str, Any]:
+2 -1
View File
@@ -59,6 +59,7 @@ from app.models import Chunk, Document
from e2e.auth_helpers import login from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
GIT_SOURCES_URL = "/git-sources.html" GIT_SOURCES_URL = "/git-sources.html"
@@ -342,7 +343,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
expect(page.locator("#sync-progress-text")).to_have_text(expected_result) expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
def _captured_systems(mock_llm: int) -> list[str]: def _captured_systems(mock_llm: int) -> list[str]:
+2 -1
View File
@@ -51,6 +51,7 @@ from app.rag.agent import IMAGE_DOC_MARKER
from e2e.auth_helpers import login from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
GIT_SOURCES_URL = "/git-sources.html" GIT_SOURCES_URL = "/git-sources.html"
@@ -280,7 +281,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
expect(page.locator("#sync-progress-text")).to_have_text(expected_result) expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
def _drill_to_source(page: Page, name: str) -> None: def _drill_to_source(page: Page, name: str) -> None:
+2 -1
View File
@@ -70,6 +70,7 @@ from app.models import Document
from e2e.auth_helpers import login from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER, IMAGE_DESCRIPTION_CONTEXT_ANSWER from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER, IMAGE_DESCRIPTION_CONTEXT_ANSWER
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -366,7 +367,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
expect(page.locator("#sync-progress-text")).to_have_text(expected_result) expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
def _ask(page: Page, app_url: str, question: str) -> None: def _ask(page: Page, app_url: str, question: str) -> None:
+2 -1
View File
@@ -88,6 +88,7 @@ from app.rag.pdf import is_blank_image
from e2e.auth_helpers import login from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
from e2e.mock_llm import IMAGE_DESCRIPTION_CONTEXT_ANSWER from e2e.mock_llm import IMAGE_DESCRIPTION_CONTEXT_ANSWER
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
SOURCE_ON = "e2e-pdf151" # the story app's archive stem (archive_source_name) SOURCE_ON = "e2e-pdf151" # the story app's archive stem (archive_source_name)
@@ -427,7 +428,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
expect(page.locator("#sync-progress-text")).to_have_text(expected_result) expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
def _drill_to_source(page: Page, name: str) -> None: def _drill_to_source(page: Page, name: str) -> None:
+2 -1
View File
@@ -59,6 +59,7 @@ from app.models import Document
from e2e.auth_helpers import login from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
from e2e.mock_llm import IMAGE_DESCRIPTION_CONTEXT_ANSWER from e2e.mock_llm import IMAGE_DESCRIPTION_CONTEXT_ANSWER
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
SOURCE_NAME = "e2e-pdf" # the archive stem (archive_source_name) SOURCE_NAME = "e2e-pdf" # the archive stem (archive_source_name)
@@ -324,7 +325,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
expect(page.locator("#sync-progress-text")).to_have_text(expected_result) expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
def _drill_to_source(page: Page, name: str) -> None: def _drill_to_source(page: Page, name: str) -> None:
+2 -1
View File
@@ -147,6 +147,7 @@ from app.rag.pdf import (
from e2e.auth_helpers import login from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
from e2e.mock_llm import PICTORIAL_PAGE_ANSWER from e2e.mock_llm import PICTORIAL_PAGE_ANSWER
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
SOURCE_NAME = "e2e-pictorial" # the archive stem (archive_source_name) SOURCE_NAME = "e2e-pictorial" # the archive stem (archive_source_name)
@@ -479,7 +480,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
expect(page.locator("#sync-progress-text")).to_have_text(expected_result) expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected_result))
def _drill_to_source(page: Page, name: str) -> None: def _drill_to_source(page: Page, name: str) -> None:
+2 -1
View File
@@ -101,6 +101,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -403,7 +404,7 @@ def _sync_from_sources_page(page: Page, app_url: str) -> dict[str, Any]:
def _expect_counts_line(page: Page, expected: str) -> None: def _expect_counts_line(page: Page, expected: str) -> None:
"""The Sources page's counts line reads exactly ``expected`` — """The Sources page's counts line reads exactly ``expected`` —
"9 added" / "0 added · 3 unchanged · 6 pruned".""" "9 added" / "0 added · 3 unchanged · 6 pruned"."""
expect(page.locator("#sync-progress-text")).to_have_text(expected, timeout=15_000) expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected), timeout=15_000)
def _add_local_source(page: Page, app_url: str, path: Path) -> str: def _add_local_source(page: Page, app_url: str, path: Path) -> str:
+3 -2
View File
@@ -52,6 +52,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -273,7 +274,7 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
# The fixture doc (one A9-format file) is the only change — the # The fixture doc (one A9-format file) is the only change — the
# settle's counts line rides the box (phase 150: the box is the # settle's counts line rides the box (phase 150: the box is the
# sole sync text surface). # sole sync text surface).
expect(page.locator("#sync-progress-text")).to_have_text("1 added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("1 added"))
# The REAL clone was imported: the fixture path is in the Sources # The REAL clone was imported: the fixture path is in the Sources
# table (the sentinel lives inside it) — via the drill-down tree # table (the sentinel lives inside it) — via the drill-down tree
@@ -297,7 +298,7 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
# Nothing re-embedded (sha256 delta) — the no-op run announces the # Nothing re-embedded (sha256 delta) — the no-op run announces the
# unchanged count instead of an empty live region (the box keeps # unchanged count instead of an empty live region (the box keeps
# its result line — phase 150). # its result line — phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 unchanged") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 1 unchanged"))
# The doc survived the prune re-import (its file is still in the # The doc survived the prune re-import (its file is still in the
# repo) — via the drill-down tree (phase 97). # repo) — via the drill-down tree (phase 97).
_expect_fixture_row(page) _expect_fixture_row(page)
+5 -3
View File
@@ -93,6 +93,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import PEAK_CLAUSE, counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -135,8 +136,9 @@ SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
#: The settled SUCCESS result line — "N added" or "N added · M #: The settled SUCCESS result line — "N added" or "N added · M
#: unchanged" (sources.js's fmtSyncResult — updated/pruned are zero on #: unchanged" (sources.js's fmtSyncResult — updated/pruned are zero on
#: the re-sync: the repo is unchanged and nothing was pruned). #: the re-sync: the repo is unchanged and nothing was pruned) with the
RESULT_LINE = re.compile(r"^(\d+) added(?: · (\d+) unchanged)?$") #: phase-187 peak clause optional at the end (null pair → no clause).
RESULT_LINE = re.compile(rf"^(\d+) added(?: · (\d+) unchanged)?{PEAK_CLAUSE}$")
#: Settle budgets: the cancel terminal lands ~1 s after the click and #: Settle budgets: the cancel terminal lands ~1 s after the click and
#: the UI's 2 s poll settles at most one tick after the terminal #: the UI's 2 s poll settles at most one tick after the terminal
@@ -547,7 +549,7 @@ def test_admin_cancel_lifecycle(page: Page, app_url: str, db_ready: None) -> Non
# Phase 150: the neutral result line rides the box (the sole sync # Phase 150: the neutral result line rides the box (the sole sync
# text surface — the retired announcer span is gone). # text surface — the retired announcer span is gone).
expect(page.locator("#sync-progress-text")).to_have_text( expect(page.locator("#sync-progress-text")).to_have_text(
"Sync cancelled", timeout=SYNC_SETTLE_TIMEOUT_MS counts_line("Sync cancelled"), timeout=SYNC_SETTLE_TIMEOUT_MS
) )
# A1: the cancel is never rendered as a failure — no error banner, # A1: the cancel is never rendered as a failure — no error banner,
# no error modal in the DOM. # no error modal in the DOM.
+2 -1
View File
@@ -69,6 +69,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -267,7 +268,7 @@ def _expect_settle_line(page: Page, expected: str) -> None:
Phase 150: the box IS the result line's surface (the retired Phase 150: the box IS the result line's surface (the retired
announcer span is gone).""" announcer span is gone)."""
expect(page.locator("#sync-progress-text")).to_have_text( expect(page.locator("#sync-progress-text")).to_have_text(
expected, timeout=SYNC_TIMEOUT_MS counts_line(expected), timeout=SYNC_TIMEOUT_MS
) )
+2 -1
View File
@@ -145,6 +145,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -948,5 +949,5 @@ def test_label_shows_the_chunk_part(
expect(page.locator(".sync-label-chunk")).to_be_hidden() expect(page.locator(".sync-label-chunk")).to_be_hidden()
expect(page.locator(".sync-label-chunk")).to_have_text("") expect(page.locator(".sync-label-chunk")).to_have_text("")
# The counts line rides the box (phase 150 — the sole sync surface). # The counts line rides the box (phase 150 — the sole sync surface).
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-error-banner")).to_be_hidden()
+6 -4
View File
@@ -129,6 +129,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import PEAK_CLAUSE, counts_line
from scripts.import_docs import repo_name from scripts.import_docs import repo_name
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -187,9 +188,10 @@ RUN_SETTLE_TIMEOUT_MS = 90_000
UI_SETTLE_TIMEOUT_MS = 15_000 UI_SETTLE_TIMEOUT_MS = 15_000
#: "Synced HH:MM" (``sources.js`` ``fmtSyncTime``) and the settled #: "Synced HH:MM" (``sources.js`` ``fmtSyncTime``) and the settled
#: SUCCESS result line (``fmtSyncResult``) — updated/pruned are 0 here. #: SUCCESS result line (``fmtSyncResult``) — updated/pruned are 0 here,
#: and the phase-187 peak clause is optional (null pair → no clause).
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}") SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
RESULT_LINE = re.compile(r"^(\d+) added(?: · (\d+) unchanged)?$") RESULT_LINE = re.compile(rf"^(\d+) added(?: · (\d+) unchanged)?{PEAK_CLAUSE}$")
#: The console noise the anonymous probes legitimately produce (this #: The console noise the anonymous probes legitimately produce (this
#: module's own tests are admin — the collector stays as the uncaught-JS #: module's own tests are admin — the collector stays as the uncaught-JS
@@ -616,7 +618,7 @@ def test_status_polls_responsive_during_long_clone(
# And it stayed in its live state: no error banner, no modal, the # And it stayed in its live state: no error banner, no modal, the
# neutral running copy, the Cancel control still there. # neutral running copy, the Cancel control still there.
expect(page.locator("#sync-progress-text")).to_have_text("Syncing…") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("Syncing…"))
expect(btn).to_be_disabled() expect(btn).to_be_disabled()
expect(cancel).to_be_visible() expect(cancel).to_be_visible()
expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-error-banner")).to_be_hidden()
@@ -784,7 +786,7 @@ def test_cancel_during_clone_reaches_terminal(
# The neutral UI settle: "Sync cancelled", retry-ready, no error. # The neutral UI settle: "Sync cancelled", retry-ready, no error.
expect(page.locator("#sync-progress-text")).to_have_text( expect(page.locator("#sync-progress-text")).to_have_text(
"Sync cancelled", timeout=UI_SETTLE_TIMEOUT_MS counts_line("Sync cancelled"), timeout=UI_SETTLE_TIMEOUT_MS
) )
expect(page.locator("#sync-label")).to_have_text( expect(page.locator("#sync-label")).to_have_text(
"Sync sources", timeout=UI_SETTLE_TIMEOUT_MS "Sync sources", timeout=UI_SETTLE_TIMEOUT_MS
+7 -4
View File
@@ -141,6 +141,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -710,13 +711,15 @@ def test_label_advances_through_the_image_span(
if snap["box"] != last: if snap["box"] != last:
seen.append(snap["box"]) seen.append(snap["box"])
last = snap["box"] last = snap["box"]
if snap["box"] == SETTLE_TEXT: if counts_line(SETTLE_TEXT).fullmatch(snap["box"]):
break break
time.sleep(0.2) time.sleep(0.2)
# --- the settle (the UI's 2 s poll renders the terminal at most one # --- the settle (the UI's 2 s poll renders the terminal at most one
# tick after it lands; the endpoint is the source of truth) --- # tick after it lands; the endpoint is the source of truth) ---
assert last == SETTLE_TEXT, f"the run never settled to the counts line: {seen[-5:]}" assert counts_line(SETTLE_TEXT).fullmatch(str(last)), (
f"the run never settled to the counts line: {seen[-5:]}"
)
terminal = _wait_terminal(app_url) terminal = _wait_terminal(app_url)
# The counts line's ground truth: the PDF + its 4 embedded-image # The counts line's ground truth: the PDF + its 4 embedded-image
# docs — one file, 5 added. # docs — one file, 5 added.
@@ -806,7 +809,7 @@ def test_label_advances_through_the_image_span(
# surface): the image part is not in it, not in the title, not in # surface): the image part is not in it, not in the title, not in
# the aria (the settle restores the ship aria value). # the aria (the settle restores the ship aria value).
box = page.locator("#sync-progress-text").inner_text() box = page.locator("#sync-progress-text").inner_text()
assert box == SETTLE_TEXT, box assert counts_line(SETTLE_TEXT).fullmatch(box), box
assert "· image" not in box and "· image" not in (btn.get_attribute("title") or "") assert "· image" not in box and "· image" not in (btn.get_attribute("title") or "")
assert btn.get_attribute("aria-label") == "Sync sources", btn.get_attribute("aria-label") assert btn.get_attribute("aria-label") == "Sync sources", btn.get_attribute("aria-label")
expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-error-banner")).to_be_hidden()
@@ -869,7 +872,7 @@ def test_mid_run_reload_re_attaches_with_image_position(
# terminal clears the position, test 1 pins the endpoint half), # terminal clears the position, test 1 pins the endpoint half),
# the terminal label, the parts cleared + empty. # the terminal label, the parts cleared + empty.
expect(page.locator("#sync-progress-text")).to_have_text( expect(page.locator("#sync-progress-text")).to_have_text(
SETTLE_TEXT, timeout=SETTLE_TIMEOUT_MS counts_line(SETTLE_TEXT), timeout=SETTLE_TIMEOUT_MS
) )
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
btn = page.locator("#sync-btn") btn = page.locator("#sync-btn")
+2 -1
View File
@@ -157,6 +157,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -688,7 +689,7 @@ def _settle(page: Page, app_url: str) -> None:
# retired announcer spoke, byte-identical: the fresh 4-file # retired announcer spoke, byte-identical: the fresh 4-file
# fixture's "4 added"), hiding only when its text is empty. # fixture's "4 added"), hiding only when its text is empty.
expect(page.locator("#sync-progress")).to_be_visible() expect(page.locator("#sync-progress")).to_be_visible()
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-error-banner")).to_be_hidden()
+3 -2
View File
@@ -69,6 +69,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -454,7 +455,7 @@ def test_healthy_sync_still_succeeds(
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
# The counts line rides the box (phase 150 — the sole sync surface). # The counts line rides the box (phase 150 — the sole sync surface).
expect(page.locator("#sync-progress-text")).to_have_text("1 added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("1 added"))
# The REAL clone was imported: the fixture path is in the Sources # The REAL clone was imported: the fixture path is in the Sources
# table (the sentinel lives inside it) — via the drill-down tree # table (the sentinel lives inside it) — via the drill-down tree
@@ -475,7 +476,7 @@ def test_healthy_sync_still_succeeds(
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 unchanged") expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 1 unchanged"))
# The doc survived the prune re-import — via the drill-down tree # The doc survived the prune re-import — via the drill-down tree
# (phase 97). # (phase 97).
_expect_fixture_row(page) _expect_fixture_row(page)
+844
View File
@@ -0,0 +1,844 @@
"""Phase 187 (task 04) E2E (Playwright, mock-only): the sync's PEAK MEMORY
is visible on the page that started the run — and it is the truth.
Source: owner incident 2026-10-15. Reconstructing "how much RAM did that
sync use?" meant reading ``systemd`` unit-teardown lines from three boots
(``brain-of-code-app.service: … 3.3G memory peak``), and boot −1 — the run
that killed the box — had NO teardown record at all, because the VM never
shut down cleanly. The app had no opinion about its own memory: no log
line, no status field, nothing on the Sources page. Phase 187 made it a
number (``app/core/memory.py``, the lifecycle lines, the status keys) and
this module is the browser proof of the last mile.
What the phase put on the wire, in the operator's words:
* ``GET /api/sync/status`` carries the ADDITIVE pair ``rss_mb`` (the
process's CURRENT resident set, MB) and ``rss_peak_mb`` (its HIGH-WATER
mark) — ``int | null``, where ``null`` means "no reading" and NEVER 0;
sampled at the run start, at every ``phase`` transition, throttled to
one read per 5 s through the import's per-file hook, and FORCED in every
terminal, which KEEPS the final pair next to the final counts;
* the Sources page appends `` · peak <N> MB`` to the result line it
already spoke (the ``fmtSyncResult`` counts line, "Sync cancelled") —
trailing text on a line that already exists, never a new control, never
a new live region, and NOTHING rendered when the pair is null.
**Which number this is (read it before comparing it to a dashboard).**
Both fields are PROCESS readings — ``VmRSS`` from ``/proc/self/status`` and
``getrusage(RUSAGE_SELF).ru_maxrss`` — NOT the host's cgroup figure. A
cgroup counts PAGE CACHE too: the incident box's app cgroup showed ~2.16 GB
of ``file`` against ~211 MB of ``anon`` while idle, and ``systemd`` printed
``3.3G memory peak`` for the unit while the uvicorn process was a fraction
of that. Neither number is wrong; LOCKED A1 pins the app to the process
one, and `README.md` (*Operational notes: the sync's memory (phase 187)*)
owns the full caveat and the operator grep
(``grep ' :: ' server.log | grep ' rss_peak_mb='``). This module never
asserts a MAGNITUDE — only that the number is present, typed, terminal,
honest (``peak >= current``, with the one exception the sampler owns: a
kernel high-water refreshed lazily is lifted to the current reading, never
invented from it), and equal on both surfaces.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the deterministic
mock plus the phase-64 ``tests/e2e/slow_llm.py`` delay proxy is what makes
the RUNNING observation a fact instead of a race: the run must outlive the
UI's 2 s status poll (and the tests' ~100 ms one) so the "no clause while
running" pin and the cancel window are both deterministic. Two files →
≈ 10 LLM calls (the two model probes, the per-file embed batches + the
summaries, the KB overview, the source-root folder summary) ≈ 5 s at
``SLOW_DELAY_S = 0.5``.
Isolation (the house shape): ONE module app on its own port (the conftest
session app owns 8123 in a combined run), the mock LLM on the conftest
port behind the proxy, a dedicated ``BOR_SOURCES_DIR`` /
``BOR_PDF_PAGE_DIR`` so no run writes into the operator's, the shared E2E
Postgres truncated BEFORE **and** AFTER every test (after waiting for the
app to hold no running sync), and the admin in through the real form login
(``tests/e2e/auth_helpers.py``). Each test seeds the same two-file
``kind=local`` source directory and registers it through the authenticated
API, so any single test re-runs alone with a fresh KB and the counts line
says ``2 added``.
Test → observable mapping (Playwright Mapping Rule — the rendered DOM is
the observable; the DOM number and the payload number are read as one
comparison, so "the page lies" cannot slip through):
1. ``test_result_line_shows_the_peak`` — a full run from the Sources page:
the box (``#sync-progress-text``) settles the counts line WITH the
trailing clause (``^2 added · peak \\d+ MB$``), the clause matches
``/peak \\d+ MB/``, the aria-live region IS the box (``role=status`` +
``aria-live=polite``, no ``aria-hidden``) and carries the SAME text,
the clause is TEXT and not an element (the box still holds exactly one
child span — no new control), and the megabytes in the DOM EQUAL the
terminal payload's ``rss_peak_mb``. Where the platform cannot know the
pair, the mirror contract is pinned instead: NO clause at all.
2. ``test_status_payload_reports_rss`` — the endpoint end to end: a tick
during ``running`` (a) reports the pair with the documented types
(``int | null``), (b) carries the pre-phase key set UNCHANGED (the
additive pin: exactly the pre-187 keys plus the memory pair) and (c)
shows NO ``peak`` clause on the page — the clause is terminal-only;
after the terminal ``rss_peak_mb`` is a POSITIVE int with
``peak >= current`` (the invariant the ONE sampler owns: the kernel
refreshes its high-water lazily, so ``_sample_rss`` lifts a lagging peak
to the current reading — a peak under the run's own footprint would be a
false number), and the same pair survives a later poll (the page
reloaded an hour later still answers).
3. ``test_anonymous_cannot_read_memory`` — the A4 exposure guard: a
logged-out ``GET /api/sync/status`` is 403 with the pre-phase body
``{"detail": "admin only"}`` — no memory number on an anonymous
surface, before or after a run — and the page shows the sign-in gate
with no sync button and no peak clause anywhere in the DOM.
4. ``test_cancelled_terminal_keeps_the_peak`` — the ``test_sync_cancel.py``
shape: cancel mid-run and the NEUTRAL "Sync cancelled" state still
renders (no banner, no modal, the button retry-ready) with the clause
present and equal to the cancelled payload's ``rss_peak_mb`` — the
run that stopped early is exactly the run whose memory matters.
5. ``test_peak_clause_fits_at_375`` — PLAN §7 at phone width: after a run
settled at 375 px, the clause is on screen, the box does not clip it
(``scrollWidth <= clientWidth`` for the box and its text), the box
stays inside the viewport and the PAGE has no horizontal overflow.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Browser, ConsoleMessage, Page, ViewportSize, expect
from sqlalchemy import text
from app.config import Settings as _Settings
from app.core.memory import rss_mb, rss_peak_mb
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
MOCK_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
#: Dedicated module ports (the house env-overridable pattern): the conftest
#: session app owns 8123 and the mock owns 8901 in a combined run.
APP_PORT = int(os.environ.get("E2E_APP_PORT_PEAKMEM", "8204"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The slow-LLM proxy's port (the conftest's mock stays on MOCK_PORT — the
#: proxy fronts it).
SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT_PEAKMEM", "8910"))
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
#: Per-LLM-request delay (the phase-64 timing convention): the run spans
#: ≈ 10 calls ≈ 5 s, i.e. ≫ the UI's 2 s status poll — the running window
#: and the cancel window are both observable, not races.
SLOW_DELAY_S = "0.5"
SOURCES_URL = "/sources.html"
#: The fixture directory's basename — the ``source`` a ``kind=local`` row
#: records (``Path(path).name``), stable whatever temp root pytest picked.
#:
#: **Nothing here is allowed to contain the word "peak"** — not the source
#: name, not a path, no marker. Several pins below ask whether the page
#: says ANYTHING about memory (the running label must be clause-free, the
#: anonymous shell must be silent), and those asks read the rendered text;
#: a fixture path like ``peakmem/notes/peak-alpha.md`` rides the running
#: label verbatim and would answer "yes" from the file name alone. So the
#: fixture is ``memline-fixture/notes/mem-*.md`` and the clause stays the
#: only thing on the page that can say the word.
SOURCE_NAME = "memline-fixture"
N_FILES = 2
FIXTURE_FILES = {
"notes/mem-alpha.md": (
"# Memory line alpha\n"
"\n"
"Head marker RESE-MEMLINE-ALPHA-6d13 opens this note.\n"
"\n"
"## Alpha body\n"
"\n"
"A small note that exists so the phase-187 suite has a real run "
"to watch: a real walk, real embeds, a real KB overview, and the "
"run's resident-set clause on the line it settles.\n"
),
"notes/mem-beta.md": (
"# Memory line beta\n"
"\n"
"Head marker RESE-MEMLINE-BETA-9a74 opens this note — the second "
"file, the counts line's denominator.\n"
),
}
#: The clause sources.js appends (``fmtSyncPeakClause``): trailing text on
#: a result line that already exists. REQUIRED here — this suite's whole
#: point is that the number is there (the optional form, for the suites
#: that only pin their own counts, lives in ``e2e.sync_helpers``).
PEAK_CLAUSE = re.compile(r" · peak (\d+) MB$")
#: The clause SHAPE, for the "this surface says nothing about memory" pins
#: (the running label, the anonymous shell). Matched case-insensitively on
#: the word, because the clause is the only thing on these pages allowed to
#: use it — which the fixture naming rule above is what keeps true.
_ANY_PEAK = re.compile(r"peak", re.IGNORECASE)
#: The settled success line: the pre-phase ``fmtSyncResult`` counts line
#: (a fresh KB of two files → "2 added", the zero "updated"/"unchanged"
#: parts omitted) with the phase-187 clause trailing it.
SUCCESS_LINE = re.compile(rf"^{N_FILES} added{PEAK_CLAUSE.pattern}$")
#: The neutral cancel line (phase 134/150 wording, byte-identical) with
#: the clause trailing it.
CANCELLED_LINE = re.compile(rf"^Sync cancelled{PEAK_CLAUSE.pattern}$")
#: "Synced HH:MM" — the local-time last-result label (sources.js's
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}$")
#: The status payload's key set BEFORE phase 187 — the additive pin's
#: left-hand side. The payload must stay exactly this set plus the memory
#: pair, forever (the same list the integration suite pins).
PRE_187_STATUS_KEYS = frozenset(
{
"state",
"started_at",
"finished_at",
"detail",
"error",
"current_file",
"files_done",
"files_total",
"current_chunk",
"chunks_total",
"images_done",
"images_total",
"phase",
"current_summary",
"summaries_done",
"summaries_total",
}
)
MEMORY_KEYS = frozenset({"rss_mb", "rss_peak_mb"})
#: The pre-phase integer count slots (their types are part of the
#: unchanged-shape pin in test 2 — never a float, never null).
_COUNT_KEYS = frozenset(
{
"files_done",
"files_total",
"current_chunk",
"chunks_total",
"images_done",
"images_total",
"summaries_done",
"summaries_total",
}
)
#: The invariant the page can rely on: ``peak >= current``. The raw kernel
#: counters do NOT give that for free (``VmRSS`` and ``ru_maxrss`` are two
#: different sources, both floored to whole MB, and the kernel refreshes the
#: high-water lazily — measured at ``rss_mb=164`` against
#: ``rss_peak_mb=162``), so the ONE sampling point
#: (:func:`app.api.sync._sample_rss`) lifts a lagging peak to the current
#: reading. Slack ZERO: a peak BELOW the run's own current footprint is a
#: false number, and the page must never show one.
MB_SLACK = 0
#: Does THIS platform answer the two helpers? Linux (the deploy target:
#: ``/proc`` + ``resource``) always does. The app under test is a
#: subprocess of this process's own OS, so this answer is the app's answer.
#: Where it is False the value pins step aside and the ABSENCE contract is
#: pinned instead (null renders nothing — never "peak ? MB", never 0).
MEMORY_KNOWABLE = rss_mb() is not None and rss_peak_mb() is not None
#: The house phone viewport at which the clause must still fit (the
#: PLAN §7 / AGENTS rule 5 width: no horizontal overflow at 375 px).
MOBILE_VIEWPORT: ViewportSize = {"width": 375, "height": 720}
#: Settle budgets: a two-file run under the proxy is ≈ 5 s and the UI's 2 s
#: poll settles at most one tick after the terminal — 90 s is an order of
#: magnitude of head-room (the house sync budget, no client timeout).
SETTLE_TIMEOUT_MS = 90_000
#: The browser logs "Failed to load resource: … 403" for the anonymous
#: probes (the whoami gate + the admin-only status fetch) — expected
#: noise, not a defect.
_CONSOLE_NOISE = re.compile(r"Failed to load resource", re.IGNORECASE)
# ---------------------------------------------------------------------------
# The fixture source
# ---------------------------------------------------------------------------
def _seed_source(root: Path) -> Path:
"""(Re-)write the two fixture files under ``root / SOURCE_NAME``.
Idempotent on purpose: every test calls it, so the tree is at its
ORIGINAL bytes whenever a test starts and a lone re-run of any single
test still sees a fresh KB with ``2 added``.
"""
base = root / SOURCE_NAME
for rel, body in FIXTURE_FILES.items():
target = base / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body, encoding="utf-8")
assert not (base / ".git").exists() # a plain directory — no git anywhere
return base
@pytest.fixture(scope="module")
def source_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The fixture tree the module app walks (the app server runs on the
same host, so the path is visible to it)."""
return _seed_source(tmp_path_factory.mktemp("bor_peak_memory"))
# ---------------------------------------------------------------------------
# The module app + the timing proxy
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def slow_llm(mock_llm: int) -> Iterator[int]:
"""The delay-injecting reverse proxy in front of the mock LLM
(``tests/e2e/slow_llm.py``) — the ONLY thing that makes the RUNNING
observation and the cancel window deterministic (module docstring)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["SLOW_LLM_DELAY_S"] = SLOW_DELAY_S
env["E2E_MOCK_PORT"] = str(MOCK_PORT)
proc = subprocess.Popen(
[
sys.executable, "-m", "uvicorn", "tests.e2e.slow_llm:app",
"--host", "127.0.0.1", "--port", str(SLOW_PORT), "--log-level", "warning",
],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{SLOW_URL}/v1/models")
yield SLOW_PORT
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_server(
mock_llm: int,
slow_llm: int,
source_dir: Path,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""The real app under test — per-module env (the conftest pattern):
``BOR_LLM_BASE_URL`` is the SLOW proxy in front of the deterministic
mock, ``BOR_GIT_SOURCES`` forced empty (each test registers its own
local row; an operator's gitignored ``.env`` must never leak a real
source into a run), and a dedicated ``BOR_SOURCES_DIR``.
"""
if USE_REAL_LLM:
pytest.skip(
"mock-only suite — the running window IS the slow proxy in "
"front of the deterministic mock (E2E_REAL_LLM unsupported)"
)
assert mock_llm and source_dir
scratch = tmp_path_factory.mktemp("bor_peak_memory_app")
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = f"{SLOW_URL}/v1"
# Mock-calibrated gate (the conftest pattern): both floors must stay
# <= the threshold or the app refuses to boot (app/config.py).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
# Instant retry waits + the code-default budget (the conftest leak
# guard — this suite never needs a retry, and never waits 5 s for one).
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
env["BOR_GIT_SOURCES"] = ""
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
env["BOR_PDF_PAGE_DIR"] = str(scratch / "pdf-pages")
proc = subprocess.Popen(
[
sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning",
],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
# ---------------------------------------------------------------------------
# Isolation
# ---------------------------------------------------------------------------
def _truncate_all() -> None:
"""Fresh registry + KB (the E2E isolation pattern, the phase-184 list:
``sources_meta`` and ``folder_summaries`` reset with the KB)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, kb_overview, "
"git_sources, sources_meta, folder_summaries"
)
)
db.commit() # without the commit the TRUNCATE rolls back
def _wait_sync_settled(base_url: str, timeout_s: float = 180.0) -> None:
"""No run may leak across a truncate: wait for a non-running status with
an own admin session (the endpoint is admin-only). Usually a no-op —
every test settles before it ends."""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
with httpx.Client(base_url=base_url, timeout=5.0) as client:
client.post("/api/login", json={"password": ADMIN_PASSWORD})
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
if r.json()["state"] != "running":
return
time.sleep(0.2)
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
@pytest.fixture(autouse=True)
def _clean(app_url: str, source_dir: Path, db_ready: None) -> Iterator[None]:
"""Per-test KB isolation: the shared E2E Postgres is truncated before
AND after every test, the app holding no running sync at both
boundaries. The fixture tree is re-seeded first (idempotent) and the
local row is registered through the authenticated API, so every test
owns a fresh two-file source and its own ``2 added``."""
_wait_sync_settled(app_url)
_truncate_all()
_seed_source(source_dir.parent)
with httpx.Client(base_url=app_url, timeout=30.0) as client:
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = client.post(
"/api/git-sources", json={"kind": "local", "path": str(source_dir)}
)
assert r.status_code == 201, r.text
yield
_wait_sync_settled(app_url)
_truncate_all()
@pytest.fixture()
def page(browser: Browser) -> Iterator[Page]:
"""The conftest's page + the console-error collector: an uncaught JS
exception (pageerror) or a console error that is not the browser's
expected 403 "Failed to load resource" noise fails the owning test at
teardown — a clause builder that threw inside ``setSyncProgress`` would
otherwise settle a blank box and read as a passing test.
The conftest's ``page`` is NOT reused here because this suite's whole
subject is the sync result line: a JS error inside the clause builder
must fail the owning test, not leak (the ``test_sync_cancel.py``
pattern)."""
pg = browser.new_page(viewport={"width": 1280, "height": 800})
errors: list[str] = []
def _on_console(msg: ConsoleMessage) -> None:
if msg.type == "error" and not _CONSOLE_NOISE.search(msg.text):
errors.append(msg.text)
pg.on("console", _on_console)
pg.on("pageerror", lambda err: errors.append(str(err)))
try:
yield pg
finally:
pg.close()
assert not errors, f"console/page errors: {errors}"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _status(page: Page, base_url: str) -> dict[str, Any]:
"""The status payload through the ADMIN browser context (the cookie
rides ``page.request`` — the same surface the page's own poll uses)."""
r = page.request.get(f"{base_url}/api/sync/status")
assert r.status == 200, r.text
body: dict[str, Any] = r.json()
return body
def _wait_sync_state(
page: Page, base_url: str, states: set[str], timeout_s: float = 90.0
) -> dict[str, Any]:
"""Poll the status endpoint at ~100 ms until the run's state is in
*states* (the deterministic layer: the UI's own poll ticks at 2 s)."""
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
body = _status(page, base_url)
if body["state"] in states:
return body
time.sleep(0.1)
raise AssertionError(f"sync did not reach {sorted(states)}: {body}")
def _open_sources(page: Page, base_url: str) -> None:
"""The real form login, landing on the Sources view (the sync button's
home) with the module graph booted."""
login(page, base_url) # lands on /sources.html
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
expect(btn).to_be_enabled()
def _click_sync(page: Page) -> None:
"""Click **Sync sources** and pin the 202 moment: the button disables
(the run is in flight), the box reveals with the bare running label —
which carries NO peak clause (the clause is terminal-only)."""
btn = page.locator("#sync-btn")
btn.click()
expect(btn).to_be_disabled()
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
def _start_from_page(page: Page, base_url: str) -> None:
"""Login on the Sources view, then the click (the two-step form of the
same helper, so a test can change the viewport BETWEEN them)."""
_open_sources(page, base_url)
_click_sync(page)
def _clause_number(text: str) -> int:
"""The MB value the clause carries (loudly absent when there is none)."""
m = PEAK_CLAUSE.search(text)
assert m, f"no peak clause in {text!r}"
return int(m.group(1))
# ---------------------------------------------------------------------------
# 1. The success terminal: the clause on the line that already existed
# ---------------------------------------------------------------------------
def test_result_line_shows_the_peak(page: Page, app_url: str, db_ready: None) -> None:
"""A real sync started from the Sources page ends with the number on
the line the page already spoke — ``2 added · peak <N> MB`` — inside
the sole aria-live region, as TEXT (no new control), and equal to the
terminal payload's ``rss_peak_mb`` (the page and the log can never
disagree, because they read the one forced terminal sample)."""
page.set_default_timeout(30_000)
_start_from_page(page, app_url)
terminal = _wait_sync_state(page, app_url, {"success", "failed", "cancelled"})
assert terminal["state"] == "success", terminal
assert terminal["error"] is None, terminal
# The UI settle (the page's own 2 s poll, one tick after the terminal).
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SETTLE_TIMEOUT_MS)
box = page.locator("#sync-progress")
expect(box).to_be_visible()
text_el = page.locator("#sync-progress-text")
line = text_el.inner_text()
if not MEMORY_KNOWABLE:
# The mirror contract: no reading → NO clause at all (never
# "peak ? MB", never "peak 0 MB"), and the counts line alone.
assert line == f"{N_FILES} added", line
assert not _ANY_PEAK.search(box.inner_text()), box.inner_text()
return
# The clause is there AND the pre-phase wording is intact (the counts
# line keeps its exact bytes; the clause trails it).
assert SUCCESS_LINE.fullmatch(line), line
assert PEAK_CLAUSE.search(line), line
assert line.startswith(f"{N_FILES} added"), line
# The clause is TEXT on a line that already exists — the box still
# holds its ONE child span (no new control, no new live region).
expect(box.locator("> *")).to_have_count(1)
# The box IS the sole aria-live region and it announces the clause
# (phase 150: one live region, one surface — and the clause rides it).
expect(box).to_have_attribute("role", "status")
expect(box).to_have_attribute("aria-live", "polite")
expect(box).not_to_have_attribute("aria-hidden")
assert PEAK_CLAUSE.search(box.inner_text()), box.inner_text()
assert box.inner_text().strip() == line.strip(), (box.inner_text(), line)
# HONESTY: the megabytes on the page ARE the terminal payload's
# ``rss_peak_mb`` — one forced reading, two surfaces.
assert terminal["rss_peak_mb"] is not None, terminal
assert _clause_number(line) == terminal["rss_peak_mb"], (line, terminal)
# And the number survives the page: a reload re-attaches the terminal
# (initSyncButton) and re-renders the SAME clause.
page.reload()
expect(page.locator("#sync-progress-text")).to_have_text(
SUCCESS_LINE, timeout=SETTLE_TIMEOUT_MS
)
assert _clause_number(page.locator("#sync-progress-text").inner_text()) == (
terminal["rss_peak_mb"]
)
# No error surface on a success settle (unchanged contract).
expect(page.locator("#sync-error-banner")).to_be_hidden()
assert page.locator(".sync-modal-backdrop").count() == 0
# ---------------------------------------------------------------------------
# 2. The payload: additive, typed, terminal-kept — and silent while running
# ---------------------------------------------------------------------------
def test_status_payload_reports_rss(page: Page, app_url: str, db_ready: None) -> None:
"""The additive pin end to end, through the browser's own authenticated
request surface: while the run is in flight the payload already carries
the pair (``int | null``) and the pre-phase keys are byte-for-byte
unchanged, while the PAGE shows no clause at all; after the terminal the
peak is a positive integer that stays with the run, and the pair keeps
the invariant the page relies on (``peak >= current`` — the sampling
point lifts the lazily-refreshed high-water, ``MB_SLACK`` is 0).
"""
page.set_default_timeout(30_000)
_start_from_page(page, app_url)
# --- during `running` ------------------------------------------------
running = _wait_sync_state(page, app_url, {"running"})
assert running["state"] == "running", running
# The additive contract on the wire: exactly the pre-phase keys plus
# the memory pair — nothing renamed, nothing dropped.
assert set(running) == PRE_187_STATUS_KEYS | MEMORY_KEYS, set(running)
for key, value in running.items():
if key in MEMORY_KEYS:
# The documented type: int | null (null is "no reading").
assert value is None or isinstance(value, int), (key, value)
elif key == "detail":
assert isinstance(value, dict), (key, value)
elif key in _COUNT_KEYS:
assert isinstance(value, int), (key, value)
else:
assert value is None or isinstance(value, (str, int, bool)), (key, value)
# The types are ints on a platform that knows them (the run start
# FORCES the first sample, so a running tick is never null here).
if MEMORY_KNOWABLE:
assert running["rss_mb"] is not None, running
assert running["rss_peak_mb"] is not None, running
assert running["rss_peak_mb"] >= running["rss_mb"] - MB_SLACK, running
# The RUNNING state shows NO clause: the box carries the live label and
# says nothing about memory (the clause is terminal-only in the UI). The
# word is the tell (the fixture names never carry it), so a clause that
# leaked into a running label fails even when its number is null-ish.
live_text = page.locator("#sync-progress-text").inner_text()
assert live_text.startswith("Syncing…"), live_text
assert not _ANY_PEAK.search(live_text), live_text
assert not _ANY_PEAK.search(page.locator("#sync-progress").inner_text())
# --- after the terminal ---------------------------------------------
terminal = _wait_sync_state(page, app_url, {"success", "failed", "cancelled"})
assert terminal["state"] == "success", terminal
assert set(terminal) == PRE_187_STATUS_KEYS | MEMORY_KEYS, set(terminal)
current, peak = terminal["rss_mb"], terminal["rss_peak_mb"]
if MEMORY_KNOWABLE:
assert peak is not None and isinstance(peak, int), terminal
assert peak > 0, terminal # a real reading, never a placeholder 0
assert current is not None and current > 0, terminal
assert peak >= current - MB_SLACK, (current, peak)
else:
assert peak is None and current is None, terminal
# The pair SURVIVES the run (no reset between the terminal and a later
# poll — the page can be reloaded an hour later and still answers).
again = _status(page, app_url)
assert (again["rss_mb"], again["rss_peak_mb"]) == (current, peak), again
# …and the pre-existing values rode the terminal unchanged too.
assert again["state"] == "success"
assert again["error"] is None
assert isinstance(again["detail"], dict) and again["detail"]["added"] == N_FILES
# ---------------------------------------------------------------------------
# 3. Anonymous: no memory number on any anonymous surface (A4)
# ---------------------------------------------------------------------------
def test_anonymous_cannot_read_memory(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
"""The exposure guard is the status route's own admin gate: a logged-out
visitor gets the pre-phase 403 with the pre-phase body, before or after
a run — and the Sources shell shows the sign-in gate with no sync button
and no memory clause anywhere in the DOM.
The probe runs in a SEPARATE browser context (``browser.new_context()``)
— this test's own page is logged in by the run above, and
``page.context.new_page()`` would SHARE that session's cookies, which
would make the "anonymous" probe an admin one."""
# A run FIRST, so the guard is proven with real numbers in existence.
_start_from_page(page, app_url)
terminal = _wait_sync_state(page, app_url, {"success"})
if MEMORY_KNOWABLE:
assert terminal["rss_peak_mb"] is not None, terminal
ctx = browser.new_context(viewport={"width": 1280, "height": 800})
try:
anon = ctx.new_page()
r = anon.request.get(f"{app_url}/api/sync/status")
assert r.status == 403, r.text
assert r.json() == {"detail": "admin only"}
# The anonymous shell: the gate is the anonymity proof, the sync
# controls are never revealed, and nothing about memory renders.
anon.goto(f"{app_url}/sources.html")
expect(anon.locator("#sources-gate")).to_be_visible(timeout=30_000)
expect(anon.locator("#sync-btn")).to_be_hidden()
expect(anon.locator("#sync-progress")).to_be_hidden()
assert not _ANY_PEAK.search(anon.locator("body").inner_text())
finally:
ctx.close()
# ---------------------------------------------------------------------------
# 4. The cancelled terminal keeps the peak (the test_sync_cancel shape)
# ---------------------------------------------------------------------------
def test_cancelled_terminal_keeps_the_peak(
page: Page, app_url: str, db_ready: None
) -> None:
"""Cancel mid-run: the run lands in the NEUTRAL ``cancelled`` terminal
(no banner, no modal, retry-ready) and the box still carries the
clause — "Sync cancelled · peak <N> MB" — equal to the cancelled
payload's ``rss_peak_mb``. A cancelled run's memory is still data: the
phase-64 keep-final-values convention covers it like the counts."""
page.set_default_timeout(30_000)
_start_from_page(page, app_url)
_wait_sync_state(page, app_url, {"running"})
cancel = page.locator("#sync-cancel-btn")
expect(cancel).to_be_visible()
expect(cancel).to_be_enabled()
cancel.click()
expect(cancel).to_be_disabled() # never stale, one request at a time
body = _wait_sync_state(page, app_url, {"cancelled"}, timeout_s=30.0)
assert body["state"] == "cancelled"
assert body["error"] is None # a cancel is intent, not failure
assert body["detail"] == {}
# The neutral settle, clause and all (the 2 s poll, one tick later).
expect(page.locator("#sync-label")).to_have_text("Sync sources", timeout=30_000)
expect(page.locator("#sync-btn")).to_be_enabled()
expect(cancel).to_be_hidden()
expect(page.locator("#sync-error-banner")).to_be_hidden()
assert page.locator(".sync-modal-backdrop").count() == 0
line = page.locator("#sync-progress-text").inner_text()
if MEMORY_KNOWABLE:
assert body["rss_peak_mb"] is not None, body
assert body["rss_peak_mb"] > 0, body
assert CANCELLED_LINE.fullmatch(line), line
assert _clause_number(line) == body["rss_peak_mb"], (line, body)
else:
assert line == "Sync cancelled", line
# The pair survives the cancel exactly like the counts do.
again = _status(page, app_url)
assert (again["rss_mb"], again["rss_peak_mb"]) == (
body["rss_mb"],
body["rss_peak_mb"],
), again
# ---------------------------------------------------------------------------
# 5. PLAN §7 at 375 px: the clause fits, the page never scrolls sideways
# ---------------------------------------------------------------------------
def test_peak_clause_fits_at_375(page: Page, app_url: str, db_ready: None) -> None:
"""The clause at phone width: the result line wraps INSIDE the bordered
box (``overflow-wrap: anywhere``) — nothing clipped, no horizontal page
overflow, the box inside the viewport (AGENTS rule 5 / PLAN §7). The
clause is a few characters of trailing text, so a layout shift or a
clipped tail at 375 px is exactly what this pins out."""
_open_sources(page, app_url)
page.set_viewport_size(MOBILE_VIEWPORT) # the phone width, then the click
page.wait_for_timeout(150) # the reflow settles before the run paints
_click_sync(page)
terminal = _wait_sync_state(page, app_url, {"success"})
text_el = page.locator("#sync-progress-text")
expect(text_el).to_have_text(
SUCCESS_LINE if MEMORY_KNOWABLE else f"{N_FILES} added",
timeout=SETTLE_TIMEOUT_MS,
)
line = text_el.inner_text()
if MEMORY_KNOWABLE:
assert PEAK_CLAUSE.search(line), line
assert _clause_number(line) == terminal["rss_peak_mb"], (line, terminal)
# The box + its text do not clip, the box stays in the viewport, and
# the PAGE itself never scrolls sideways at 375 px.
page.wait_for_timeout(150) # the reflow settles before the measure
geometry = page.evaluate(
"""() => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const b = box.getBoundingClientRect();
return {
boxScrollWidth: box.scrollWidth,
boxClientWidth: box.clientWidth,
textScrollWidth: textEl.scrollWidth,
textClientWidth: textEl.clientWidth,
left: b.left,
right: b.right,
height: b.height,
docScroll: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
text: textEl.textContent,
};
}"""
)
assert geometry["height"] > 0, geometry
assert geometry["boxScrollWidth"] <= geometry["boxClientWidth"], geometry
assert geometry["textScrollWidth"] <= geometry["textClientWidth"], geometry
assert geometry["left"] >= 0, geometry
assert geometry["right"] <= MOBILE_VIEWPORT["width"] + 1, geometry
assert geometry["docScroll"] <= geometry["innerWidth"] + 1, geometry
assert PEAK_CLAUSE.search(geometry["text"]) or not MEMORY_KNOWABLE, geometry
+9 -4
View File
@@ -183,6 +183,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import PEAK_CLAUSE, counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -267,8 +268,12 @@ WALL_BUDGET_S = 600.0
#: The settle's counts line (sources.js's fmtSyncResult — the first #: The settle's counts line (sources.js's fmtSyncResult — the first
#: part is always "N added"; the rest are conditional, " · "-joined — #: part is always "N added"; the rest are conditional, " · "-joined —
#: the shape the pre-phase announcer spoke, byte-identical). #: the shape the pre-phase announcer spoke, byte-identical) plus the
RESULT_LINE = re.compile(r"^\d+ added( · \d+ (updated|unchanged|pruned))*$") #: optional phase-187 peak clause — the counts are still anchored, so
#: a mutated counts line still fails this shape.
RESULT_LINE = re.compile(
rf"^\d+ added( · \d+ (updated|unchanged|pruned))*{PEAK_CLAUSE}$"
)
#: A broken git source — the failure trigger (test 8): a ``file://`` #: A broken git source — the failure trigger (test 8): a ``file://``
#: URL with no repository behind it (the URL shape passes add-time #: URL with no repository behind it (the URL shape passes add-time
@@ -1146,7 +1151,7 @@ def test_settled_state_restores_the_pill(
# counts line (the fmtSyncResult shape the pre-phase announcer # counts line (the fmtSyncResult shape the pre-phase announcer
# spoke, byte-identical). # spoke, byte-identical).
expect(page.locator("#sync-progress")).to_be_visible() expect(page.locator("#sync-progress")).to_be_visible()
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
# The label parts are restored — the settle label carries no count # The label parts are restored — the settle label carries no count
# or chunk part. # or chunk part.
count = page.locator(".sync-label-count") count = page.locator(".sync-label-count")
@@ -1243,7 +1248,7 @@ def test_settled_cancel_shows_the_result_line(
# line (byte-identical to the pre-phase announcer's string)… # line (byte-identical to the pre-phase announcer's string)…
expect(page.locator("#sync-progress")).to_be_visible(timeout=30_000) expect(page.locator("#sync-progress")).to_be_visible(timeout=30_000)
expect(page.locator("#sync-progress-text")).to_have_text( expect(page.locator("#sync-progress-text")).to_have_text(
"Sync cancelled", timeout=30_000 counts_line("Sync cancelled"), timeout=30_000
) )
# …the button settles retry-ready… # …the button settles retry-ready…
expect(page.locator("#sync-label")).to_have_text("Sync sources", timeout=30_000) expect(page.locator("#sync-label")).to_have_text("Sync sources", timeout=30_000)
+10 -3
View File
@@ -130,6 +130,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line, strip_peak_clause
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -497,12 +498,18 @@ def _sync_from_sources_page(page: Page, base_url: str) -> dict[str, Any]:
def _expect_counts_line(page: Page, expected: str) -> None: def _expect_counts_line(page: Page, expected: str) -> None:
"""The counts line — the owner-visible sync report on the single sync text """The counts line — the owner-visible sync report on the single sync text
surface (phase 150; sources.js fmtSyncResult).""" surface (phase 150; sources.js fmtSyncResult)."""
expect(page.locator("#sync-progress-text")).to_have_text(expected, timeout=15_000) expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected), timeout=15_000)
def _counts_line(page: Page) -> str: def _counts_line(page: Page) -> str:
"""The counts line as text — the string test 4 compares across the apps.""" """The counts line as text — the string test 4 compares across the apps.
return str(page.locator("#sync-progress-text").inner_text())
Phase 187 (task 03): the peak clause is stripped first. The byte-
identity this test pins is the KB's (the same ten files report the same
counts under both cadences); the megabytes belong to the process that
ran them and differ between the two module apps by design.
"""
return strip_peak_clause(str(page.locator("#sync-progress-text").inner_text()))
def _open_level(page: Page, *names: str) -> None: def _open_level(page: Page, *names: str) -> None:
+2 -1
View File
@@ -98,6 +98,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -497,7 +498,7 @@ def test_fresh_sync_settles_same_day_shape(
# the status is the source of truth for the settle; the UI's 2 s # the status is the source of truth for the settle; the UI's 2 s
# poll renders it at most one tick later). # poll renders it at most one tick later).
expect(page.locator("#sync-progress-text")).to_have_text( expect(page.locator("#sync-progress-text")).to_have_text(
f"{len(SYNC_FILES)} added", timeout=SYNC_TIMEOUT_MS counts_line(f"{len(SYNC_FILES)} added"), timeout=SYNC_TIMEOUT_MS
) )
btn = page.locator("#sync-btn") btn = page.locator("#sync-btn")
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
+2 -1
View File
@@ -124,6 +124,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -807,7 +808,7 @@ def test_sync_button_names_the_summary_phase(
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy") expect(btn).not_to_have_attribute("aria-busy")
# The counts line rides the box (phase 150 — the sole sync surface). # The counts line rides the box (phase 150 — the sole sync surface).
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+7 -6
View File
@@ -132,6 +132,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -647,11 +648,11 @@ def test_upload_toast_then_navigate_away(
# "(n/m)" count is IN the full string (the never-clipped pin), # "(n/m)" count is IN the full string (the never-clipped pin),
# and the button title carries the same full string (A4 — one # and the button title carries the same full string (A4 — one
# source, two surfaces). # source, two surfaces).
expect(page.locator("#sync-progress-text")).to_have_text( expect(page.locator("#sync-progress-text")).to_have_text(counts_line(
re.compile( re.compile(
rf"^Syncing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)" rf"^Syncing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)"
r"(?: · chunk \d+/\d+)?$" r"(?: · chunk \d+/\d+)?$"
), )),
timeout=SETTLE_TIMEOUT_MS, timeout=SETTLE_TIMEOUT_MS,
) )
# The box is the VISIBLE running surface (the count's old # The box is the VISIBLE running surface (the count's old
@@ -686,7 +687,7 @@ def test_upload_toast_then_navigate_away(
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy") expect(btn).not_to_have_attribute("aria-busy")
# The counts line rides the box (phase 150 — the sole sync surface). # The counts line rides the box (phase 150 — the sole sync surface).
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
# Phase 97: the catalog is the drill-down tree — drill source → # Phase 97: the catalog is the drill-down tree — drill source →
# folder `docs` before the per-level row asserts (the flat total # folder `docs` before the per-level row asserts (the flat total
# no longer exists in one tbody). # no longer exists in one tbody).
@@ -844,11 +845,11 @@ def test_sync_live_file_label(
# "(n/m)" count is IN the full string (the never-clipped pin), # "(n/m)" count is IN the full string (the never-clipped pin),
# and the button title carries the same full string (A4 — one # and the button title carries the same full string (A4 — one
# source, two surfaces). # source, two surfaces).
expect(page.locator("#sync-progress-text")).to_have_text( expect(page.locator("#sync-progress-text")).to_have_text(counts_line(
re.compile( re.compile(
rf"^Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md \(\d+/{N_FILES}\)" rf"^Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md \(\d+/{N_FILES}\)"
r"(?: · chunk \d+/\d+)?$" r"(?: · chunk \d+/\d+)?$"
), )),
timeout=SETTLE_TIMEOUT_MS, timeout=SETTLE_TIMEOUT_MS,
) )
# The box is the VISIBLE running surface (the count's old # The box is the VISIBLE running surface (the count's old
@@ -885,7 +886,7 @@ def test_sync_live_file_label(
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy") expect(btn).not_to_have_attribute("aria-busy")
# The counts line rides the box (phase 150 — the sole sync surface). # The counts line rides the box (phase 150 — the sole sync surface).
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added") expect(page.locator("#sync-progress-text")).to_have_text(counts_line(f"{N_FILES} added"))
# Phase 97: the catalog is the drill-down tree — drill source → # Phase 97: the catalog is the drill-down tree — drill source →
# folder `notes` before the per-level row asserts (the flat total # folder `notes` before the per-level row asserts (the flat total
# no longer exists in one tbody). # no longer exists in one tbody).
+4 -1
View File
@@ -111,6 +111,7 @@ from e2e.conftest import (
USE_REAL_LLM, USE_REAL_LLM,
_wait_http, _wait_http,
) )
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
@@ -574,7 +575,9 @@ def test_ignore_list_then_sync_scans(
expect(page.locator("#sync-label")).to_have_text( expect(page.locator("#sync-label")).to_have_text(
SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS
) )
expect(page.locator("#sync-progress-text")).to_have_text("2 added", timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-progress-text")).to_have_text(
counts_line("2 added"), timeout=SYNC_TIMEOUT_MS
)
expect(page.locator("#sync-btn")).to_be_enabled() expect(page.locator("#sync-btn")).to_be_enabled()
expect(page.locator("#sync-btn")).not_to_have_attribute("aria-busy") expect(page.locator("#sync-btn")).not_to_have_attribute("aria-busy")
+44 -1
View File
@@ -4,12 +4,19 @@ Runs the real import pipeline (walk → chunk → embed → upsert) into the
local compose Postgres, then checks the DB state *and* the API shape a local compose Postgres, then checks the DB state *and* the API shape a
browser would consume. Embeddings come from a deterministic in-process browser would consume. Embeddings come from a deterministic in-process
fake, so no network is needed. fake, so no network is needed.
Phase 187 (task 02) pins the PLAN §9 cron-safe summary line's ONE new
trailing field (``rss_mb=``) on both the initial import and the
idempotent re-run, with every earlier field byte-identical.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import re
from pathlib import Path from pathlib import Path
import pytest
from sqlalchemy import func, select, text from sqlalchemy import func, select, text
from app.models import Chunk, Document from app.models import Chunk, Document
@@ -36,9 +43,22 @@ EXPECTED_DOCS = {
} }
def test_import_fixtures_end_to_end(admin_client, db) -> None: #: Phase 187 (task 02): the ONE rendering :func:`app.core.memory.fmt_mb`
#: allows for an MB field — ``?`` when the platform cannot know, else a
#: positive integer (never ``None``, never a float or a negative).
MB = r"(?:\?|[1-9][0-9]*)"
def test_import_fixtures_end_to_end(
admin_client, db, caplog: pytest.LogCaptureFixture
) -> None:
db.execute(text("TRUNCATE chunks, documents, query_log")) db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit() db.commit()
# Phase 187 (task 02): the PLAN §9 cron-safe summary line gains a
# trailing ``rss_mb=`` — captured for BOTH runs below (the initial
# import and the idempotent re-run), the level set once so every
# ``import_sources`` call of this test logs at INFO.
caplog.set_level(logging.INFO, logger="app.importer")
llm = FakeEmbedder() llm = FakeEmbedder()
summary = asyncio.run(import_sources([FIXTURES], llm, session=db)) summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
@@ -111,5 +131,28 @@ def test_import_fixtures_end_to_end(admin_client, db) -> None:
assert s2.unchanged == 13 and s2.added == 0 assert s2.unchanged == 13 and s2.added == 0
assert len(llm.calls) == calls_before # unchanged → no embedding requests assert len(llm.calls) == calls_before # unchanged → no embedding requests
# Phase 187 (task 02): every run's summary line ends with the ONE
# appended memory field, and the pre-phase fields are byte-identical
# (LOCKED A2 — append-only, so every existing grep keeps working).
summary_lines = [
r.getMessage()
for r in caplog.records
if r.getMessage().startswith("import: summary files=")
]
assert len(summary_lines) == 2, summary_lines
for line in summary_lines:
assert re.search(rf" rss_mb={MB}$", line), line
assert summary_lines[0].startswith(
"import: summary files=13 added=13 updated=0 unchanged=0 pruned=0 errors=0 "
)
assert re.search(
rf"formats=md:5,container:1,j2:1,json:1,network:1,py:1,txt:1,volume:1,yaml:1"
rf" rss_mb={MB}$",
summary_lines[0],
), summary_lines[0]
assert summary_lines[1].startswith(
"import: summary files=13 added=0 updated=0 unchanged=13 pruned=0 errors=0 "
)
db.execute(text("TRUNCATE chunks, documents, query_log")) db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit() db.commit()
+377 -14
View File
@@ -86,12 +86,20 @@ empty, the final file counts kept, the chunk position cleared) and a
second run after the cancel completes to ``success`` (the restart is second run after the cancel completes to ``success`` (the restart is
clean — the stale cancel event did not bleed in, and only the clean — the stale cancel event did not bleed in, and only the
completed run bumps the sources version). completed run bumps the sources version).
Phase 187 (task 02): every sync LIFECYCLE line carries the process
memory — ``sync: started … rss_mb=N``, and ``… rss_mb=N rss_peak_mb=M``
on all three terminals (success, cancelled, failed) plus the importer's
``import: summary …``. The fields are APPENDED (LOCKED A2), so every
pre-phase prefix below is byte-identical and the pins here assert BOTH
halves of that contract.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
import re
import subprocess import subprocess
import textwrap import textwrap
import threading import threading
@@ -109,6 +117,7 @@ from sqlalchemy.orm import Session
from app.api import sync as sync_api from app.api import sync as sync_api
from app.config import Settings from app.config import Settings
from app.core.memory import fmt_mb, rss_mb, rss_peak_mb
from app.db import SessionLocal, db_available from app.db import SessionLocal, db_available
from app.main import app as fastapi_app from app.main import app as fastapi_app
from app.models import Document, GitSource from app.models import Document, GitSource
@@ -130,14 +139,21 @@ def _fresh_sync_state() -> Iterator[None]:
Phase 134 (task 01): the module ``_cancel_event`` joins the reset — Phase 134 (task 01): the module ``_cancel_event`` joins the reset —
a stale (set) event from a previous test's cancelled run would a stale (set) event from a previous test's cancelled run would
otherwise bleed into the next run's boundary checks.""" otherwise bleed into the next run's boundary checks.
Phase 187 (task 03): so does the memory-sampling guard
(``_rss_sampled_at``) — module-global state beside ``_status``.
Leaving it set would make one test's sample answer for the next
one's "did the run read the number?" assertion."""
sync_api._status = sync_api.SyncStatus() sync_api._status = sync_api.SyncStatus()
sync_api._task = None sync_api._task = None
sync_api._cancel_event = asyncio.Event() sync_api._cancel_event = asyncio.Event()
sync_api._rss_sampled_at = float("-inf")
yield yield
sync_api._status = sync_api.SyncStatus() sync_api._status = sync_api.SyncStatus()
sync_api._task = None sync_api._task = None
sync_api._cancel_event = asyncio.Event() sync_api._cancel_event = asyncio.Event()
sync_api._rss_sampled_at = float("-inf")
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -307,6 +323,21 @@ def _wait_phase(client: TestClient, want: str, timeout: float = 5.0) -> dict:
time.sleep(0.02) time.sleep(0.02)
#: Phase 187 (task 02): the ONE rendering :func:`app.core.memory.fmt_mb`
#: allows for an MB field — ``?`` when the platform cannot know (no
#: ``/proc``, no ``resource``), else a positive integer (never ``None``,
#: never a float or a negative).
MB = r"(?:\?|[1-9][0-9]*)"
def _log_lines(caplog: pytest.LogCaptureFixture, prefix: str) -> list[str]:
"""Captured messages starting with ``prefix`` — the lifecycle lines
are pinned by their PRE-phase prefix, so the append-only contract
(LOCKED A2) is visible in the assertion itself.
"""
return [r.getMessage() for r in caplog.records if r.getMessage().startswith(prefix)]
class FakeImportSources: class FakeImportSources:
"""Records every ``import_sources`` call; returns a canned summary.""" """Records every ``import_sources`` call; returns a canned summary."""
@@ -546,6 +577,10 @@ def test_admin_sync_success_reports_full_detail(
"current_summary": None, "current_summary": None,
"summaries_done": 0, "summaries_done": 0,
"summaries_total": 0, "summaries_total": 0,
# Phase 187 (task 03): the memory keys — null/null on a run that
# never happened (never 0, and the page renders no peak clause).
"rss_mb": None,
"rss_peak_mb": None,
} }
r = sync_client.post("/api/sync") r = sync_client.post("/api/sync")
@@ -1567,7 +1602,11 @@ def test_summaries_phase_reports_the_affected_count_as_total(
def test_git_failure_marks_failed_and_skips_import( def test_git_failure_marks_failed_and_skips_import(
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
repo_url = f"file://{tmp_path / 'bad.git'}" repo_url = f"file://{tmp_path / 'bad.git'}"
_seed(db, repo_url) _seed(db, repo_url)
@@ -1592,9 +1631,9 @@ def test_git_failure_marks_failed_and_skips_import(
monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview) monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview)
_login(sync_client) _login(sync_client)
assert sync_client.post("/api/sync").status_code == 202 with caplog.at_level(logging.INFO, logger="app.api.sync"):
assert sync_client.post("/api/sync").status_code == 202
body = _poll(sync_client, "failed") body = _poll(sync_client, "failed")
assert "bad.git" in body["error"] # the failing repo is named assert "bad.git" in body["error"] # the failing repo is named
assert "fatal: repository not found" in body["error"] assert "fatal: repository not found" in body["error"]
assert body["detail"] == {} assert body["detail"] == {}
@@ -1613,6 +1652,21 @@ def test_git_failure_marks_failed_and_skips_import(
# Phase 53: a FAILED sync never bumps — the version is untouched. # Phase 53: a FAILED sync never bumps — the version is untouched.
assert current_sources_version(db) == 0 assert current_sources_version(db) == 0
# Phase 187 (task 02): the failed funnel keeps its traceback AND
# gains ONE sanitized one-line summary (LOCKED A2 — appended, so the
# pre-phase ``sync: failed`` label is untouched). Asserted before the
# restart below, which fails a SECOND time.
tracebacks = [
r for r in caplog.records if r.getMessage() == "sync: failed" and r.exc_info
]
assert len(tracebacks) == 1, "logger.exception must still write the traceback"
failed = _log_lines(caplog, "sync: failed state=failed")
assert len(failed) == 1, failed
assert failed[0].startswith("sync: failed state=failed files_done=0 files_total=0")
assert re.search(
rf" rss_mb={MB} rss_peak_mb={MB} error=.*bad\.git", failed[0]
), failed[0]
# A failed run leaves the system restartable: a new POST is accepted. # A failed run leaves the system restartable: a new POST is accepted.
assert sync_client.post("/api/sync").status_code == 202 assert sync_client.post("/api/sync").status_code == 202
_poll(sync_client, "failed") _poll(sync_client, "failed")
@@ -1855,7 +1909,11 @@ def test_missing_local_dir_fails_loudly_and_imports_nothing(
def test_import_error_is_reported_with_credentials_masked( def test_import_error_is_reported_with_credentials_masked(
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
repo_url = f"file://{tmp_path / 'repo.git'}" repo_url = f"file://{tmp_path / 'repo.git'}"
_seed(db, repo_url) _seed(db, repo_url)
@@ -1892,9 +1950,9 @@ def test_import_error_is_reported_with_credentials_masked(
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True)) monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
_login(sync_client) _login(sync_client)
assert sync_client.post("/api/sync").status_code == 202 with caplog.at_level(logging.INFO, logger="app.api.sync"):
assert sync_client.post("/api/sync").status_code == 202
body = _poll(sync_client, "failed") body = _poll(sync_client, "failed")
assert "*****@aipi.reeseapps.com" in body["error"] # credentials masked assert "*****@aipi.reeseapps.com" in body["error"] # credentials masked
assert "user:secret" not in body["error"] assert "user:secret" not in body["error"]
assert "connection refused" in body["error"] # the reason survives assert "connection refused" in body["error"] # the reason survives
@@ -1905,6 +1963,16 @@ def test_import_error_is_reported_with_credentials_masked(
assert body["current_summary"] is None assert body["current_summary"] is None
assert body["summaries_done"] == 0 and body["summaries_total"] == 0 assert body["summaries_done"] == 0 and body["summaries_total"] == 0
# Phase 187 (task 02): the one-line failed summary logs the SAME
# masked string the status field carries — the raw exception text
# never reaches the log through this line (the ``logger.exception``
# traceback is the pre-phase surface, unchanged).
failed = _log_lines(caplog, "sync: failed state=failed")
assert len(failed) == 1, failed
assert "*****@aipi.reeseapps.com" in failed[0]
assert "user:secret" not in failed[0]
assert re.search(rf" rss_mb={MB} rss_peak_mb={MB} error=", failed[0]), failed[0]
# --- phase 41: model probe (fail fast before any clone) -------------------- # --- phase 41: model probe (fail fast before any clone) --------------------
@@ -2350,12 +2418,29 @@ def test_cancel_mid_run_lands_in_cancelled_then_resyncs_clean(
assert body["files_done"] == 1 and body["files_total"] == 3 assert body["files_done"] == 1 and body["files_total"] == 3
# ... and the chunk position clears (the phase-129 convention). # ... and the chunk position clears (the phase-129 convention).
assert body["current_chunk"] == 0 and body["chunks_total"] == 0 assert body["current_chunk"] == 0 and body["chunks_total"] == 0
# PLAN §9: the ample-logging line for the converted cancel. # Phase 187 (task 03): the CANCELLED terminal keeps the memory pair
assert any( # too — a cancelled run is exactly the run an operator wants the
r.getMessage() # number for (the walk stopped, the box is still hot), and the
== "sync: cancelled by user state=cancelled files_done=1 files_total=3" # phase-64 keep-final-values convention covers it like the counts.
for r in caplog.records if _memory_is_knowable():
assert body["rss_mb"] is not None and body["rss_peak_mb"] is not None, body
assert body["rss_peak_mb"] >= body["rss_mb"] - _MB_SLACK, body
else:
assert body["rss_mb"] is None and body["rss_peak_mb"] is None, body
# PLAN §9: the ample-logging line for the converted cancel. Phase
# 187 (task 02) APPENDS the two memory fields (LOCKED A2) — the
# pre-phase prefix is byte-identical, the tail is `?` or a positive
# integer, and NOTHING else was inserted or reworded.
cancelled = _log_lines(caplog, "sync: cancelled by user ")
assert len(cancelled) == 1, cancelled
assert cancelled[0].startswith(
"sync: cancelled by user state=cancelled files_done=1 files_total=3"
) )
assert re.fullmatch(
rf"sync: cancelled by user state=cancelled files_done=1 files_total=3"
rf" rss_mb={MB} rss_peak_mb={MB}",
cancelled[0],
), cancelled[0]
# The cancelled run never reached the version bump. # The cancelled run never reached the version bump.
assert current_sources_version(db) == 0 assert current_sources_version(db) == 0
@@ -2518,3 +2603,281 @@ def test_git_row_branch_mismatch_button_path_fails_loud(
assert proc.returncode == 0 and proc.stdout.strip() == "main" assert proc.returncode == 0 and proc.stdout.strip() == "main"
assert (dest / "a.md").is_file() assert (dest / "a.md").is_file()
assert not (dest / "b.md").exists() assert not (dest / "b.md").exists()
# --- phase 187 (task 02): the lifecycle lines carry the memory numbers ----
def test_success_lifecycle_lines_carry_memory_fields(
sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The owner incident (2026-10-15) turned "how much RAM did that sync
use?" into archaeology across three boots' ``systemd`` teardown lines
— with one boot missing entirely. From now on ONE grep answers it:
``sync: started … rss_mb=N`` and ``sync: done detail=… rss_mb=N
rss_peak_mb=M``. Both fields are APPENDED after the existing ones
(LOCKED A2), so the pre-phase prefix stays byte-identical; the value
is a positive integer MB or ``?`` where the platform cannot know
(:mod:`app.core.memory` never raises).
"""
repo_url = f"file://{tmp_path / 'repo.git'}"
_seed(db, repo_url)
_stub_env(monkeypatch)
monkeypatch.setattr(
sync_api,
"get_settings",
lambda: _settings(sources_dir=str(tmp_path / "bor")),
)
_, fake_clone = _fake_clone()
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
_stub_probe(monkeypatch)
monkeypatch.setattr(
sync_api, "import_sources", FakeImportSources(ImportSummary(files=1, added=1))
)
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries())
_login(sync_client)
with caplog.at_level(logging.INFO, logger="app.api.sync"):
assert sync_client.post("/api/sync").status_code == 202
body = _poll(sync_client, "success")
assert body["state"] == "success"
started = _log_lines(caplog, "sync: started ")
assert len(started) == 1, started
assert started[0].startswith("sync: started repos=1 origin=db git=1 local=0")
assert re.fullmatch(
rf"sync: started repos=1 origin=db git=1 local=0 rss_mb={MB}", started[0]
), started[0]
done = _log_lines(caplog, "sync: done ")
assert len(done) == 1, done
# The pre-existing prefix, the detail dict still first, the two new
# fields trailing — nothing renamed, reordered or re-typed.
assert done[0].startswith("sync: done detail={'files': 1,")
assert re.search(rf" rss_mb={MB} rss_peak_mb={MB}$", done[0]), done[0]
# --- phase 187 (task 03): the status carries the memory pair --------------
#
# The same numbers the lifecycle lines carry, on the surface the Sources
# page polls: two ADDITIVE keys (rss_mb / rss_peak_mb, int | null),
# null until a run samples them and the FINAL pair in every terminal
# (the phase-64 keep-final-values convention). The exposure guard is the
# route's own admin gate — the Sources view is admin-only (PLAN B5), so
# no memory number ever leaves the app on an anonymous surface.
#: The status payload's key set BEFORE phase 187 — the additive pin's
#: left-hand side (the payload is exactly this set plus the memory pair,
#: forever).
_PRE_187_STATUS_KEYS = frozenset(
{
"state",
"started_at",
"finished_at",
"detail",
"error",
"current_file",
"files_done",
"files_total",
"current_chunk",
"chunks_total",
"images_done",
"images_total",
"phase",
"current_summary",
"summaries_done",
"summaries_total",
}
)
#: Phase 187 (task 03): the two additive memory keys.
_MEMORY_KEYS = frozenset({"rss_mb", "rss_peak_mb"})
#: The pair is read from two DIFFERENT kernel counters and the high-water
#: refreshes LAZILY, so a raw ``rss_peak_mb()`` really can sit under a raw
#: ``rss_mb()`` (measured: ``rss_mb=164`` against ``rss_peak_mb=162``) —
#: the nuance ``app/core/memory.py`` documents. Task 04 moved the fix to
#: the ONE place the pair is published (:func:`app.api.sync._sample_rss`,
#: which lifts a lagging peak to the current reading), so the slack here is
#: ZERO and the assertion is the invariant itself: a reported peak below
#: the run's own current footprint is a false number, not a smaller one.
_MB_SLACK = 0
def _memory_is_knowable() -> bool:
"""Does THIS platform answer the two helpers? Linux (the deploy
target: ``/proc`` + ``resource``) always does; a runner without
either gets null/null and the value pins step aside — the SHAPE pins
never do, because ``null`` is the honest answer there too."""
return rss_mb() is not None and rss_peak_mb() is not None
def _stub_a_one_file_run(
monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""The shortest honest run: one git row, a stub clone that writes one
file, a stubbed import (the runner's own numbers are what's under
test, not the importer's)."""
_seed(db, f"file://{tmp_path / 'repo.git'}")
_stub_env(monkeypatch)
monkeypatch.setattr(
sync_api,
"get_settings",
lambda: _settings(sources_dir=str(tmp_path / "bor")),
)
_, fake_clone = _fake_clone()
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
_stub_probe(monkeypatch)
monkeypatch.setattr(
sync_api, "import_sources", FakeImportSources(ImportSummary(files=1, added=1))
)
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries())
def test_memory_status_keys_are_the_only_addition_and_null_before_a_run(
sync_client: TestClient,
) -> None:
"""The additive pin on the wire: the payload's key set is EXACTLY the
pre-phase-187 set plus ``rss_mb`` / ``rss_peak_mb``, and a run that
never happened answers ``null`` / ``null`` — not 0, not a plausible
number. That null is also what makes the Sources page render the
counts line with no peak clause after an app restart."""
_login(sync_client)
body = sync_client.get("/api/sync/status").json()
assert set(body) == _PRE_187_STATUS_KEYS | _MEMORY_KEYS, set(body)
assert body["rss_mb"] is None
assert body["rss_peak_mb"] is None
# The pre-existing shapes, unchanged: the state word, the two ISO
# slots, the counts, the phase machine.
assert body["state"] == "idle"
assert body["started_at"] is None and body["finished_at"] is None
assert body["detail"] == {} and body["error"] is None
assert body["files_done"] == 0 and body["files_total"] == 0
assert body["current_chunk"] == 0 and body["chunks_total"] == 0
assert body["images_done"] == 0 and body["images_total"] == 0
assert body["phase"] is None and body["current_summary"] is None
assert body["summaries_done"] == 0 and body["summaries_total"] == 0
def test_success_terminal_status_keeps_the_final_memory_pair(
sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""After a run the payload answers the incident's question directly:
both keys are integers here (Linux), the peak is the run's
high-water next to the final counts (within the documented MB
slack), and the pair SURVIVES the run — a later poll, i.e. the page
reloaded after the sync, still shows it. The ``sync: done`` line
logs the SAME pair: one reading, two surfaces."""
_stub_a_one_file_run(monkeypatch, db, tmp_path)
_login(sync_client)
with caplog.at_level(logging.INFO, logger="app.api.sync"):
assert sync_client.post("/api/sync").status_code == 202
body = _poll(sync_client, "success")
assert body["state"] == "success"
current, peak = body["rss_mb"], body["rss_peak_mb"]
assert current is None or isinstance(current, int)
assert peak is None or isinstance(peak, int)
if _memory_is_knowable():
assert current is not None and peak is not None, body
assert current > 0 and peak > 0, body
assert peak >= current - _MB_SLACK, (
"the published pair broke its peak >= current invariant — the"
" sampling point must lift a lazily-refreshed high-water to the"
f" current reading (slack {_MB_SLACK} MB):"
f" current={current} peak={peak}"
)
# The terminal keeps the pair (no re-sample, no reset between the
# run and the next poll — the page can be reloaded at leisure).
again = sync_client.get("/api/sync/status").json()
assert (again["rss_mb"], again["rss_peak_mb"]) == (current, peak), again
# Log / payload agreement: the line the operator greps and the key
# the page renders come off the same reading.
done = _log_lines(caplog, "sync: done ")
assert len(done) == 1, done
assert done[0].endswith(
f"rss_mb={fmt_mb(current)} rss_peak_mb={fmt_mb(peak)}"
), done[0]
# The pre-existing payload is untouched by all of this.
assert set(again) == _PRE_187_STATUS_KEYS | _MEMORY_KEYS
assert again["files_done"] == 0 or isinstance(again["files_done"], int)
def test_failed_terminal_keeps_the_memory_pair(
sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
) -> None:
"""A run that dies is exactly the run whose memory matters, so the
FAILED funnel samples too: the payload keeps the pair beside the
sanitized error (the task-02 line logs the same pair)."""
repo_url = f"file://{tmp_path / 'missing.git'}"
_seed(db, repo_url)
_stub_env(monkeypatch)
monkeypatch.setattr(
sync_api,
"get_settings",
lambda: _settings(sources_dir=str(tmp_path / "bor")),
)
_stub_probe(monkeypatch)
def broken_clone(url: str, dest: Path | str, branch: str | None = None) -> Path:
raise GitSyncError(f"git clone failed for {url}")
monkeypatch.setattr(sync_api, "clone_or_pull", broken_clone)
monkeypatch.setattr(
sync_api,
"import_sources",
FakeImportSources(ImportSummary()), # must never run
)
_login(sync_client)
assert sync_client.post("/api/sync").status_code == 202
body = _poll(sync_client, "failed")
assert "missing.git" in body["error"]
assert body["detail"] == {}
if _memory_is_knowable():
assert body["rss_mb"] is not None and body["rss_peak_mb"] is not None, body
assert body["rss_peak_mb"] >= body["rss_mb"] - _MB_SLACK, body
else: # a platform that cannot know still answers, with null
assert body["rss_mb"] is None and body["rss_peak_mb"] is None, body
def test_memory_pair_is_never_an_anonymous_surface(
sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
) -> None:
"""A4's exposure guard on the wire: the status route is
``require_admin``, and the new keys ride that gate — an anonymous
visitor gets the pre-phase 403 with the pre-phase body, with or
without a run in flight (the Sources view is the only place the
number renders, and it is admin-only)."""
anonymous = TestClient(fastapi_app)
r = anonymous.get("/api/sync/status")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
_stub_a_one_file_run(monkeypatch, db, tmp_path)
_login(sync_client)
assert sync_client.post("/api/sync").status_code == 202
_poll(sync_client, "success")
r = anonymous.get("/api/sync/status")
assert r.status_code == 403, "no memory number on an anonymous surface"
assert r.json() == {"detail": "admin only"}
+285
View File
@@ -0,0 +1,285 @@
"""Unit tests: ``app.core.memory`` — process RSS helpers (phase 187, task 01).
Every branch of the four helpers, with the filesystem stubbed through the
module's path constants (``PROC_STATUS_PATH`` / ``CGROUP_PEAK_PATH`` →
``tmp_path`` files, so ``open`` is never mocked globally) and ``resource``
stubbed through ``memory._resource`` (the guarded-import binding):
* the REAL ``/proc/self/status`` read on a Linux runner — ``rss_mb()`` is a
positive int and provably comes from the ``VmRSS`` parse, not the
fallback (the fallback is pinned to a sentinel that never surfaces);
* the kB→MB floor arithmetic (1500 kB → 1 MB);
* missing file, unparseable value, value-less ``VmRSS:`` line, and a file
without any ``VmRSS`` line — each falls back to ``rss_peak_mb()``;
* the non-Linux path: ``/proc`` open raises AND ``getrusage`` is patched →
the proxy value; both unavailable → ``None``;
* ``rss_peak_mb`` arithmetic, the no-``resource`` ``None``, and the
never-raise wrapper when ``getrusage`` itself raises;
* ``cgroup_peak_mb`` present (bytes→MB), absent, garbage, empty → the
latter three are ``None``;
* ``fmt_mb(None) == "?"``, ``fmt_mb(0) == "0"`` (0 is a value, not
missing), plain ints as digits;
* the purity contract: stdlib imports only — no ``app.`` import — and the
docstring carries the "Pure stdlib" line the phase completion criteria
require.
"""
from __future__ import annotations
import ast
import importlib
import inspect
import sys
from types import SimpleNamespace
from typing import Any
import pytest
import app.core.memory as memory
#: Stub whose ``getrusage`` answers with a fixed ``ru_maxrss`` (kB on
#: Linux) so fallback assertions are deterministic.
def _fake_resource(maxrss_kb: int) -> Any:
return SimpleNamespace(
RUSAGE_SELF=0,
getrusage=lambda who: SimpleNamespace(ru_maxrss=maxrss_kb),
)
#: Stub whose ``getrusage`` raises — the never-raise contract probe.
def _raising_resource(exc: Exception) -> Any:
def getrusage(who: int) -> Any:
raise exc
return SimpleNamespace(RUSAGE_SELF=0, getrusage=getrusage)
# ---------------------------------------------------------------------------
# rss_mb — the real /proc read (Linux) and the stubbed failure modes
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="needs /proc/self/status")
def test_rss_mb_reads_real_vm_rss_on_linux(monkeypatch: pytest.MonkeyPatch) -> None:
# Pin the FALLBACK to a negative sentinel: a positive int can therefore
# only come from the real VmRSS parse, not from getrusage.
monkeypatch.setattr(memory, "rss_peak_mb", lambda: -1)
value = memory.rss_mb()
assert value is not None
assert isinstance(value, int)
assert value > 0
def test_rss_mb_parses_vm_rss_kb_flooring_to_mb(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
status = tmp_path / "status"
status.write_text("Name:\tpython\nVmRSS:\t 1500 kB\nVmSize:\t999999 kB\n")
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
monkeypatch.setattr(memory, "_resource", _fake_resource(999_999))
assert memory.rss_mb() == 1 # 1500 kB // 1024 — integer floor, not round
def test_rss_mb_missing_file_falls_back_to_getrusage(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(tmp_path / "nope"))
monkeypatch.setattr(memory, "_resource", _fake_resource(9216)) # 9 MB
assert memory.rss_mb() == 9
def test_rss_mb_unparseable_value_falls_back(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
status = tmp_path / "status"
status.write_text("VmRSS:\tnot-a-number kB\n")
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
monkeypatch.setattr(memory, "_resource", _fake_resource(2048))
assert memory.rss_mb() == 2
def test_rss_mb_value_less_line_falls_back(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
# "VmRSS:" with no value: the split()[1] raises IndexError internally,
# the suppress turns it into the rss_peak_mb() proxy.
status = tmp_path / "status"
status.write_text("VmRSS:\n")
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
monkeypatch.setattr(memory, "_resource", _fake_resource(3072))
assert memory.rss_mb() == 3
def test_rss_mb_file_without_vm_rss_line_falls_back(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
status = tmp_path / "status"
status.write_text("Name:\tpython\nVmSize:\t999999 kB\nVmHWM:\t888888 kB\n")
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(status))
monkeypatch.setattr(memory, "_resource", _fake_resource(1024))
assert memory.rss_mb() == 1
def test_rss_mb_returns_none_when_proc_and_resource_both_unavailable(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(memory, "PROC_STATUS_PATH", str(tmp_path / "nope"))
monkeypatch.setattr(memory, "_resource", None)
assert memory.rss_mb() is None
# ---------------------------------------------------------------------------
# rss_peak_mb — arithmetic, the guarded import, the never-raise wrapper
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("maxrss_kb", "expected_mb"),
[(1024, 1), (1023, 0), (1500, 1), (4_294_967_296, 4_194_304)],
)
def test_rss_peak_mb_arithmetic(
monkeypatch: pytest.MonkeyPatch, maxrss_kb: int, expected_mb: int
) -> None:
monkeypatch.setattr(memory, "_resource", _fake_resource(maxrss_kb))
assert memory.rss_peak_mb() == expected_mb
def test_rss_peak_mb_none_without_resource_module(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(memory, "_resource", None)
assert memory.rss_peak_mb() is None
def test_guarded_import_degrades_to_none_when_resource_is_missing(
tmp_path: Any,
) -> None:
# The guarded-import branch itself: a platform WITHOUT the Unix-only
# `resource` module must still import this module, and every helper
# must answer None (no /proc, no proxy). `sys.modules[name] = None`
# makes `import resource` raise ImportError, so re-executing the
# module exercises the except arm for real (no pragma).
saved = sys.modules.get("resource")
sys.modules["resource"] = None # type: ignore[assignment]
try:
importlib.reload(memory)
assert memory._resource is None
assert memory.rss_peak_mb() is None
memory.PROC_STATUS_PATH = str(tmp_path / "nope") # reset by reload
assert memory.rss_mb() is None
finally:
if saved is not None:
sys.modules["resource"] = saved
else: # pragma: no cover - non-Unix runners never had it
sys.modules.pop("resource", None)
importlib.reload(memory) # restore the real bindings for later tests
def test_module_reloads_with_real_resource_after_the_missing_module_probe() -> None:
# Sibling pin: the finally-reload above restored the module, so the
# guarded import took the happy arm again (order-independent: this
# only passes if _resource is the live module on this platform).
if not sys.platform.startswith("linux"):
pytest.skip("resource exists on Linux")
assert memory._resource is not None
def test_rss_peak_mb_never_raises_when_getrusage_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(memory, "_resource", _raising_resource(OSError("boom")))
assert memory.rss_peak_mb() is None
def test_rss_peak_mb_real_call_on_linux_yields_positive_int() -> None:
if not sys.platform.startswith("linux"):
pytest.skip("resource.getrusage is the Linux deploy path")
value = memory.rss_peak_mb()
assert value is not None
assert isinstance(value, int)
assert value > 0
# ---------------------------------------------------------------------------
# cgroup_peak_mb — present / absent / garbage / empty
# ---------------------------------------------------------------------------
def test_cgroup_peak_mb_present_converts_bytes_to_mb(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
# 2,306,867,200 bytes == 2200 MiB — the incident box's ~2.16 GB of
# cgroup page-cache-inflated "peak" (module docstring caveat).
peak = tmp_path / "memory.peak"
peak.write_text("2306867200\n")
monkeypatch.setattr(memory, "CGROUP_PEAK_PATH", str(peak))
assert memory.cgroup_peak_mb() == 2200
def test_cgroup_peak_mb_absent_is_none(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(memory, "CGROUP_PEAK_PATH", str(tmp_path / "memory.peak"))
assert memory.cgroup_peak_mb() is None
@pytest.mark.parametrize("body", ["max", "", " \n", "1048576 kB"])
def test_cgroup_peak_mb_garbage_is_none(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any, body: str
) -> None:
peak = tmp_path / "memory.peak"
peak.write_text(body)
monkeypatch.setattr(memory, "CGROUP_PEAK_PATH", str(peak))
assert memory.cgroup_peak_mb() is None
# ---------------------------------------------------------------------------
# fmt_mb — the "?" rendering
# ---------------------------------------------------------------------------
def test_fmt_mb_renders_none_as_question_mark_and_values_as_digits() -> None:
assert memory.fmt_mb(None) == "?"
assert memory.fmt_mb(0) == "0" # 0 is a VALUE, not missing
assert memory.fmt_mb(512) == "512"
# ---------------------------------------------------------------------------
# Purity contract (completion criterion: stdlib only, no app. imports)
# ---------------------------------------------------------------------------
def test_module_imports_stdlib_only_and_declares_pure_stdlib() -> None:
tree = ast.parse(inspect.getsource(memory))
imported: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom):
if node.level: # relative import — still inside the app package
imported.add("app")
elif node.module:
imported.add(node.module.split(".")[0])
assert "app" not in imported
# __future__, contextlib, types are static; `resource` is the guarded
# Unix-only stdlib import — everything must stay standard library.
assert imported <= {"__future__", "contextlib", "resource", "types"}, imported
assert "Pure stdlib" in (memory.__doc__ or "")
+6 -1
View File
@@ -667,7 +667,12 @@ def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
success = _fn(js, "applySyncSuccess") success = _fn(js, "applySyncSuccess")
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success
i_settle = success.find("settleSyncButton(") i_settle = success.find("settleSyncButton(")
i_counts = success.find("setSyncProgress(fmtSyncResult(status.detail))") # Phase 187 (task 03): the clause builder rides the SAME writer call
# (the counts line + its trailing " · peak N MB", null rendering no
# clause), so the ordering pin reads the new argument list.
i_counts = success.find(
"setSyncProgress(fmtSyncResult(status.detail) + fmtSyncPeakClause(status))"
)
assert -1 < i_settle < i_counts, ( assert -1 < i_settle < i_counts, (
"the counts line (the box's result line) follows the settle label" "the counts line (the box's result line) follows the settle label"
) )
+18 -3
View File
@@ -18,6 +18,7 @@ import base64
import hashlib import hashlib
import logging import logging
import os import os
import re
import textwrap import textwrap
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -54,6 +55,11 @@ from tests.fakes import FakeEmbedder
#: (``Settings().import_extension_set`` — now seventeen formats). #: (``Settings().import_extension_set`` — now seventeen formats).
DEFAULT_EXTS = frozenset({".md", ".markdown", ".txt", ".yaml", ".yml", ".json", ".py"}) DEFAULT_EXTS = frozenset({".md", ".markdown", ".txt", ".yaml", ".yml", ".json", ".py"})
#: Phase 187 (task 02): the ONE rendering :func:`app.core.memory.fmt_mb`
#: allows for an MB field — ``?`` when the platform cannot know, else a
#: positive integer (never ``None``, never a float or a negative).
MB = r"(?:\?|[1-9][0-9]*)"
class _PoisonEmbedder(FakeEmbedder): class _PoisonEmbedder(FakeEmbedder):
"""Fails (like a real endpoint) on any text containing 'poison'.""" """Fails (like a real endpoint) on any text containing 'poison'."""
@@ -1001,7 +1007,10 @@ def test_import_summary_log_line_includes_summary_counters(
and the phase-151 ``pdf_images_deduped`` / ``pdf_images_blank_skipped`` and the phase-151 ``pdf_images_deduped`` / ``pdf_images_blank_skipped``
counters (the parser's per-PDF drop counts, next to counters (the parser's per-PDF drop counts, next to
``pdf_pictorial_pages``) sit after ``dates_updated``, before ``pdf_pictorial_pages``) sit after ``dates_updated``, before
``formats``.""" ``formats``. Phase 187 (task 02) APPENDS a trailing ``rss_mb=``
(LOCKED A2 — the memory the run sits at, ``?`` when unknowable); the
pre-phase text up to and including ``formats=…`` is byte-identical.
"""
s = ImportSummary() s = ImportSummary()
s.files, s.added, s.chunks, s.embed_batches = 3, 3, 5, 4 s.files, s.added, s.chunks, s.embed_batches = 3, 3, 5, 4
s.summaries, s.summary_errors = 2, 1 s.summaries, s.summary_errors = 2, 1
@@ -1019,7 +1028,7 @@ def test_import_summary_log_line_includes_summary_counters(
with caplog.at_level(logging.INFO, logger="app.importer"): with caplog.at_level(logging.INFO, logger="app.importer"):
s.log() s.log()
line = caplog.records[-1].getMessage() line = caplog.records[-1].getMessage()
assert line == ( assert line.startswith(
"import: summary files=3 added=3 updated=0 unchanged=0 pruned=0 errors=0 " "import: summary files=3 added=3 updated=0 unchanged=0 pruned=0 errors=0 "
"chunks=5 embed_batches=4 summaries=2 summary_errors=1 summary_backfilled=1 " "chunks=5 embed_batches=4 summaries=2 summary_errors=1 summary_backfilled=1 "
"dates_updated=0 images_failed=0 svg_indexed=4 image_context_backfilled=2 " "dates_updated=0 images_failed=0 svg_indexed=4 image_context_backfilled=2 "
@@ -1027,6 +1036,9 @@ def test_import_summary_log_line_includes_summary_counters(
"pdf_images_deduped=3 pdf_images_blank_skipped=1 " "pdf_images_deduped=3 pdf_images_blank_skipped=1 "
"formats=yaml:2,md:1" "formats=yaml:2,md:1"
) )
# Phase 187 (task 02): the ONE appended field, `?` (unknowable) or a
# positive integer MB — never `None`, never a negative or a float.
assert re.fullmatch(rf".* rss_mb={MB}", line), line
def test_prune_removes_files_now_excluded_by_format_filter(db, tmp_path: Path) -> None: def test_prune_removes_files_now_excluded_by_format_filter(db, tmp_path: Path) -> None:
@@ -1082,7 +1094,10 @@ def test_formats_counter_counts_extensionless_name_token(
for r in caplog.records for r in caplog.records
if "import: summary files=" in r.getMessage() if "import: summary files=" in r.getMessage()
) )
assert line.endswith("formats=dockerfile:1,md:1") # Phase 187 (task 02): ``formats=`` is no longer the LAST field
# (`` rss_mb=…`` follows), but its position and value are intact.
assert " formats=dockerfile:1,md:1" in line
assert re.search(rf"formats=dockerfile:1,md:1 rss_mb={MB}$", line), line
assert "unknown" not in line assert "unknown" not in line
finally: finally:
_cleanup_source(db, root.name) _cleanup_source(db, root.name)
+485 -11
View File
@@ -203,6 +203,10 @@ RETIRED_ID = "-".join(("sync", "result")) # built at runtime: 3.12+ folds
HAVE_NODE = shutil.which("node") is not None HAVE_NODE = shutil.which("node") is not None
#: Phase 187 (task 02): what :func:`app.core.memory.fmt_mb` may render for
#: an MB field — ``?`` when the platform cannot know, else a positive int.
MB = r"(?:\?|[1-9][0-9]*)"
def _text(path: Path) -> str: def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}" assert path.is_file(), f"missing frontend file: {path}"
@@ -1112,9 +1116,13 @@ def test_sources_js_renders_result_line_and_banner_directly() -> None:
for other subscribers.""" for other subscribers."""
js = _text(SOURCES_JS) js = _text(SOURCES_JS)
success = _body(js, "applySyncSuccess") success = _body(js, "applySyncSuccess")
assert "setSyncProgress(fmtSyncResult(status.detail))" in success, ( # Phase 187 (task 03): the counts line keeps its builder and gains
"the counts live in status.detail — a bare status renders all zeros" # the TRAILING peak clause — a null pair appends the empty string, so
) # every pre-phase line stays byte-identical.
assert (
"setSyncProgress(fmtSyncResult(status.detail) + fmtSyncPeakClause(status))"
in success
), "the counts live in status.detail — a bare status renders all zeros"
assert "loadTree()" in success, ( assert "loadTree()" in success, (
"the catalog re-fetches live on a successful sync (phase 97: the tree)" "the catalog re-fetches live on a successful sync (phase 97: the tree)"
) )
@@ -1319,14 +1327,21 @@ def fresh_sync_status() -> Iterator[None]:
Phase 134 (task 01): the module ``_cancel_event`` joins the reset — Phase 134 (task 01): the module ``_cancel_event`` joins the reset —
a stale (set) event from a previous test's cancelled run would a stale (set) event from a previous test's cancelled run would
otherwise bleed into the next run's boundary checks.""" otherwise bleed into the next run's boundary checks.
Phase 187 (task 03): the memory-sampling guard (``_rss_sampled_at``)
joins the reset — it is module-global state beside ``_status``, and a
test that pins "the per-file hook sampled" has to know whether the
guard says "never read" or "still inside the 5 s window"."""
sync_api._status = sync_api.SyncStatus() sync_api._status = sync_api.SyncStatus()
sync_api._task = None sync_api._task = None
sync_api._cancel_event = asyncio.Event() sync_api._cancel_event = asyncio.Event()
sync_api._rss_sampled_at = float("-inf")
yield yield
sync_api._status = sync_api.SyncStatus() sync_api._status = sync_api.SyncStatus()
sync_api._task = None sync_api._task = None
sync_api._cancel_event = asyncio.Event() sync_api._cancel_event = asyncio.Event()
sync_api._rss_sampled_at = float("-inf")
class _GatedClone: class _GatedClone:
@@ -1567,9 +1582,11 @@ def test_idle_status_pins_full_shape_including_progress_keys(
) -> None: ) -> None:
"""Idle: the three phase-64 progress keys ride along as null/0/0, """Idle: the three phase-64 progress keys ride along as null/0/0,
the two phase-129 chunk keys as 0/0, the two phase-152 image keys the two phase-129 chunk keys as 0/0, the two phase-152 image keys
as 0/0, the four phase-98 phase keys as null/0/0/0, and EVERY as 0/0, the four phase-98 phase keys as null/0/0/0, the two phase-187
pre-existing key is unchanged — the full response dict is pinned, memory keys as null/null (NO reading is not 0 — and the Sources page
so the current UI and every existing consumer keep working.""" renders no peak clause for it), and EVERY pre-existing key is
unchanged — the full response dict is pinned, so the current UI and
every existing consumer keep working."""
assert sync_api.sync_status() == { assert sync_api.sync_status() == {
"state": "idle", "state": "idle",
"started_at": None, "started_at": None,
@@ -1592,6 +1609,10 @@ def test_idle_status_pins_full_shape_including_progress_keys(
"current_summary": None, "current_summary": None,
"summaries_done": 0, "summaries_done": 0,
"summaries_total": 0, "summaries_total": 0,
# Phase 187 (task 03): the memory keys — null/null idle (never
# sampled: never 0, never an invented number).
"rss_mb": None,
"rss_peak_mb": None,
} }
@@ -2562,10 +2583,15 @@ def test_cancel_endpoint_stops_the_in_flight_run(
# through the closure in flight is gone; the FILE counts above # through the closure in flight is gone; the FILE counts above
# keep their final values). # keep their final values).
assert s["images_done"] == 0 and s["images_total"] == 0 assert s["images_done"] == 0 and s["images_total"] == 0
# PLAN §9: the ample-logging line for the converted cancel. # PLAN §9: the ample-logging line for the converted cancel. Phase
# 187 (task 02) APPENDS the memory fields (LOCKED A2) — the pre-phase
# prefix stays byte-identical, the tail is `?` or a positive int.
assert any( assert any(
r.getMessage() re.fullmatch(
== "sync: cancelled by user state=cancelled files_done=1 files_total=3" r"sync: cancelled by user state=cancelled files_done=1 files_total=3"
rf" rss_mb={MB} rss_peak_mb={MB}",
r.getMessage(),
)
for r in caplog.records for r in caplog.records
) )
@@ -3091,7 +3117,9 @@ def test_sources_js_apply_sync_cancelled_is_neutral() -> None:
js = _text(SOURCES_JS) js = _text(SOURCES_JS)
body = _body(js, "applySyncCancelled") body = _body(js, "applySyncCancelled")
assert 'settleSyncButton("Sync sources")' in body assert 'settleSyncButton("Sync sources")' in body
assert 'setSyncProgress("Sync cancelled")' in body # Phase 187 (task 03): the neutral copy keeps its exact wording and
# the peak clause trails it (a null pair appends the empty string).
assert 'setSyncProgress("Sync cancelled" + fmtSyncPeakClause(status))' in body
assert "hideSyncError()" in body assert "hideSyncError()" in body
assert "emitSyncStatus(status)" in body assert "emitSyncStatus(status)" in body
assert "showSyncModal" not in body, "cancel is intent — no modal" assert "showSyncModal" not in body, "cancel is intent — no modal"
@@ -3123,3 +3151,449 @@ def test_sources_js_section_header_documents_the_cancel_contract() -> None:
assert "5. sync cancelled" in header, "the tree's cancelled branch (the 5th)" assert "5. sync cancelled" in header, "the tree's cancelled branch (the 5th)"
assert "8. both idle" in header, "the tree is now 8 branches" assert "8. both idle" in header, "the tree is now 8 branches"
assert "#sync-cancel-btn" in header, "the control's contract is documented" assert "#sync-cancel-btn" in header, "the control's contract is documented"
# ---------- phase 187 (task 03): the run's memory on the status ----------
#
# The owner incident (2026-10-15): "how much RAM did that sync use?" was
# answered by reading systemd unit-teardown lines out of three boots —
# and the boot that killed the VM had none. Tasks 01/02 made the log
# carry the number; task 03 puts the SAME pair on the status object, so
# the page that started the run answers without a shell. The pins below
# hold the three things that make that honest: the pair is ADDITIVE (the
# key set is exactly the old set plus two), it is SAMPLED from ONE place
# (forced at the run start / every phase transition / every terminal,
# throttled to one read per 5 s in the per-file hook — LOCKED A4), and a
# reading that does not exist stays null (never 0, never invented).
#: The status payload's key set BEFORE phase 187 — the additive pin's
#: left-hand side: the payload is exactly this set plus
#: :data:`_MEMORY_KEYS`, nothing else, forever.
_PRE_187_STATUS_KEYS = frozenset(
{
"state",
"started_at",
"finished_at",
"detail",
"error",
"current_file",
"files_done",
"files_total",
"current_chunk",
"chunks_total",
"images_done",
"images_total",
"phase",
"current_summary",
"summaries_done",
"summaries_total",
}
)
#: Phase 187 (task 03): the two additive memory keys.
_MEMORY_KEYS = frozenset({"rss_mb", "rss_peak_mb"})
def test_status_key_set_is_the_old_set_plus_the_memory_pair(
fresh_sync_status: None,
) -> None:
"""The key-set pin (D4: "Additive only — every pre-existing key
keeps its name, type, and meaning"): the payload is EXACTLY the
pre-phase-187 set ``∪ {rss_mb, rss_peak_mb}`` — a fifth sub-position
or a renamed field fails here even when the value assertions pass."""
keys = set(sync_api.sync_status())
assert keys == _PRE_187_STATUS_KEYS | _MEMORY_KEYS, keys
assert _MEMORY_KEYS.isdisjoint(_PRE_187_STATUS_KEYS)
class _FakeClock:
"""``sync_api.time`` stubbed: the sampler is the module's only
``time.monotonic()`` reader, so the 5 s guard is assertable without
sleeping (the test moves the clock, never the wall)."""
def __init__(self, start: float = 10_000.0) -> None:
self.now = start
def monotonic(self) -> float:
return self.now
class _StubMemory:
"""The two ``app.core.memory`` reads as a scripted pair-per-read
source: sample *n* returns ``readings[n]`` (the last pair repeats)
and :attr:`reads` counts the samples, so "sampled at most once per
5 s" is a number and not a comment.
Both helpers are patched on ``app.api.sync`` (the module under test
imports them by name), which is also what proves the sampler reads
them from ONE place: a call counter that disagrees with
``_status`` is a second, unsampled write path."""
def __init__(
self,
monkeypatch: pytest.MonkeyPatch,
readings: Sequence[tuple[int | None, int | None]],
) -> None:
self.readings = list(readings)
self.reads = 0
monkeypatch.setattr(sync_api, "rss_mb", self._current)
monkeypatch.setattr(sync_api, "rss_peak_mb", self._peak)
def _pair(self) -> tuple[int | None, int | None]:
return self.readings[min(self.reads, len(self.readings) - 1)]
def _current(self) -> int | None:
return self._pair()[0]
def _peak(self) -> int | None:
value = self._pair()[1]
self.reads += 1 # the PAIR is one sample — counted when it completes
return value
def test_sample_rss_stores_both_readings_from_one_sample(
fresh_sync_status: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``_sample_rss`` is the ONE writer of the pair: both fields land
from the same read, so the status and the lifecycle line can never
report two different instants."""
monkeypatch.setattr(sync_api, "time", _FakeClock())
memory = _StubMemory(monkeypatch, [(412, 1_936)])
sync_api._sample_rss(force=True)
assert sync_api._status.rss_mb == 412
assert sync_api._status.rss_peak_mb == 1_936
assert memory.reads == 1
def test_sample_rss_throttles_the_unforced_call_and_force_always_reads(
fresh_sync_status: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""LOCKED A4 / D3, pinned on the guard itself: the FIRST call reads
(never sampled), the next one inside ``_RSS_SAMPLE_INTERVAL_S`` is a
NO-OP, one past the interval reads again, and ``force=True`` reads
regardless AND re-arms the window (which is what keeps the 27k-file
walk at ~600 reads)."""
clock = _FakeClock(10_000.0)
monkeypatch.setattr(sync_api, "time", clock)
memory = _StubMemory(
monkeypatch, [(100, 200), (101, 201), (102, 202), (103, 203), (104, 204)]
)
assert sync_api._RSS_SAMPLE_INTERVAL_S == 5.0, "the phase's sampling window"
sync_api._sample_rss() # never sampled → reads
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (100, 200)
clock.now += sync_api._RSS_SAMPLE_INTERVAL_S - 0.001
sync_api._sample_rss() # inside the window → no-op
assert memory.reads == 1
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (100, 200)
clock.now += 0.002 # the window closed
sync_api._sample_rss()
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (101, 201)
sync_api._sample_rss(force=True) # forced, immediately → reads
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (102, 202)
sync_api._sample_rss() # …and the forced read re-armed the window
assert memory.reads == 3, "the forced sample closed the window it opened"
clock.now += sync_api._RSS_SAMPLE_INTERVAL_S
sync_api._sample_rss()
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (103, 203)
assert memory.reads == 4
def test_sample_rss_keeps_none_as_unknown(
fresh_sync_status: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A platform that cannot know (no ``/proc``, no ``resource``) stays
``None`` on the status — never 0, never a plausible number. The wire
form is JSON null and the UI renders no clause for it (the same
``?``-is-not-0 rule :func:`app.core.memory.fmt_mb` holds for the
log)."""
monkeypatch.setattr(sync_api, "time", _FakeClock())
_StubMemory(monkeypatch, [(None, None)])
sync_api._sample_rss(force=True)
assert sync_api._status.rss_mb is None
assert sync_api._status.rss_peak_mb is None
body = sync_api.sync_status()
assert body["rss_mb"] is None and body["rss_peak_mb"] is None
def test_sample_rss_never_publishes_a_peak_below_the_current_reading(
fresh_sync_status: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The pair's invariant, pinned at the ONE sampling point (task 04).
``VmRSS`` and ``ru_maxrss`` are two kernel counters read at two
instants, both floored to whole MB, and the high-water refreshes
LAZILY — so a growing run really does report ``rss_mb`` above
``rss_peak_mb`` (measured on this repo's own suite at 164 vs 162).
A "peak" under the process's own current footprint is a FALSE
number, not a conservative one, so the sampler lifts it to the
current reading: a lower bound on the truth, never an invention.
Without this the invariant every consumer (both lifecycle lines, the
status payload, the page's clause) now relies on would be a coin
flip — and the phase's own integration gate proved it."""
monkeypatch.setattr(sync_api, "time", _FakeClock())
_StubMemory(monkeypatch, [(412, 410)]) # the lazy-kernel case
sync_api._sample_rss(force=True)
assert sync_api._status.rss_mb == 412
assert sync_api._status.rss_peak_mb == 412, "the lagging peak is raised"
assert sync_api.sync_status()["rss_peak_mb"] == 412, "the wire agrees"
def test_sample_rss_does_not_fabricate_an_unknown_peak(
fresh_sync_status: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The normalization's edge: an UNKNOWABLE peak stays ``None``.
Lifting a lagging reading is a bound on a real measurement; inventing
a peak from the current reading on a platform where
:func:`app.core.memory.rss_peak_mb` answered ``None`` would be the
invented number the phase forbids (``null`` renders no clause, ``0``
is a value). So the clamp needs BOTH readings to be real."""
monkeypatch.setattr(sync_api, "time", _FakeClock())
_StubMemory(monkeypatch, [(412, None)])
sync_api._sample_rss(force=True)
assert sync_api._status.rss_mb == 412
assert sync_api._status.rss_peak_mb is None
def test_run_samples_at_start_phase_transitions_and_terminal(
fresh_sync_status: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The sampling POINTS, walked through the real runner: run start →
``import`` → ``overview`` → ``summaries`` → the success terminal, one
forced read each, and the per-file progress hook's throttled call
adds NOTHING inside the window the run start just armed (LOCKED A4).
The last pair is the number the incident was about: the terminal
KEEPS it (the phase-64 keep-final-values convention) and the
``sync: done`` line logs the very same pair the payload reports —
one reading, two surfaces, no disagreement between the page and the
log an operator could be argued with."""
clock = _FakeClock(10_000.0)
monkeypatch.setattr(sync_api, "time", clock)
# run start, import, overview, summaries, done (the hook is throttled).
memory = _StubMemory(
monkeypatch,
[(10, 20), (11, 21), (12, 22), (13, 23), (14, 4_321)],
)
clone = _GatedClone(threading.Event(), threading.Event())
fake_import = _GatedImport(
ImportSummary(files=3, added=1, unchanged=2),
threading.Event(),
threading.Event(),
)
_patch_sync_seams(monkeypatch, tmp_path, fake_import, clone)
with caplog.at_level(logging.INFO, logger="app.api.sync"):
thread, errors = _start_run()
try:
assert clone.started.wait(5.0), "the run never reached the clone phase"
# The run start sampled BEFORE any pipeline step: a run that
# fails in the clone already has a number on the status.
s = sync_api.sync_status()
assert s["state"] == "running"
assert (s["rss_mb"], s["rss_peak_mb"]) == (10, 20)
clone.release.set()
assert fake_import.started.wait(5.0), "the run never reached the import"
# Import phase: the transition forced the second read, and
# the hook's own call (this fake fires one file) is inside
# the window — still 2 reads, not 3 (A4 in numbers).
s = sync_api.sync_status()
assert s["phase"] == "import"
assert fake_import.hook_calls == [("repo", "notes/deep.md", 1, 3)]
assert (s["rss_mb"], s["rss_peak_mb"]) == (11, 21)
assert memory.reads == 2, "the per-file hook never bypasses the guard"
fake_import.release.set()
finally:
clone.release.set()
fake_import.release.set()
thread.join(10.0)
assert errors == [], f"the runner raised: {errors!r}"
# The terminal pair is the FINAL reading, kept next to the counts,
# and it survives the run (the page shows it after the settle).
s = sync_api.sync_status()
assert s["state"] == "success"
assert (s["rss_mb"], s["rss_peak_mb"]) == (14, 4_321)
assert s["files_done"] == 1 and s["files_total"] == 3
done = [
r.getMessage()
for r in caplog.records
if r.getMessage().startswith("sync: done detail=")
]
assert len(done) == 1, done
assert done[0].endswith("rss_mb=14 rss_peak_mb=4321"), done[0]
def test_per_file_hook_samples_once_the_window_closes(
fresh_sync_status: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""The other half of D3: the number on the page MOVES. The hook
fires once per file, and once the 5 s window has closed the next
file's call re-reads — so a long walk reports the memory climbing
(PLAN §7.4's never-stale contract) at ~600 reads per 27k files
instead of 27k."""
clock = _FakeClock(10_000.0)
monkeypatch.setattr(sync_api, "time", clock)
memory = _StubMemory(monkeypatch, [(100, 200), (110, 210), (140, 260)])
clone = _GatedClone(threading.Event(), threading.Event())
fake_import = _GatedImport(
ImportSummary(files=1, added=1),
threading.Event(),
threading.Event(),
)
# The fake fires ONE file, parks, then returns — so the hook is
# called once mid-run and the test calls it a second time with the
# window closed (the second file of a real walk).
_patch_sync_seams(monkeypatch, tmp_path, fake_import, clone)
thread, errors = _start_run()
try:
assert clone.started.wait(5.0), "the run never reached the clone phase"
clone.release.set()
assert fake_import.started.wait(5.0), "the run never reached the import"
s = sync_api.sync_status()
assert s["phase"] == "import"
assert (s["rss_mb"], s["rss_peak_mb"]) == (110, 210) # the transition
assert memory.reads == 2, "the hook's call inside the window read nothing"
clock.now += sync_api._RSS_SAMPLE_INTERVAL_S + 1.0
sync_api._sample_rss() # the NEXT file's hook call
assert (sync_api._status.rss_mb, sync_api._status.rss_peak_mb) == (140, 260)
fake_import.release.set()
finally:
clone.release.set()
fake_import.release.set()
thread.join(10.0)
assert errors == [], f"the runner raised: {errors!r}"
assert memory.reads >= 3
# ---------- phase 187 (task 03): the peak clause on the result line ------
def test_sources_js_peak_clause_is_a_trailing_clause_with_a_null_guard() -> None:
"""The frontend SOURCE pin (the browser behavior is
tests/e2e/test_sync_peak_memory_visible.py, phase 187 task 04):
* ONE builder, reading ``status.rss_peak_mb ?? status.rss_mb`` (the
high-water mark, the current reading as the fall-back);
* a non-number (null / undefined / garbage) renders the EMPTY string
— never ``peak ? MB``, never ``peak null MB``;
* it is APPENDED to the two result strings that already exist
(``fmtSyncResult(...)``, ``"Sync cancelled"``) through the single
box writer — no new control, no new live region, no replaced
state string;
* NOTHING else reads it: the running label, the failed box (the
banner + modal own the error, byte-identical per phase 150) and
the upload settles never grow a clause;
* the shell markup is untouched — the box is still one text span."""
js = _text(SOURCES_JS)
builder = _body(js, "fmtSyncPeakClause")
assert "status.rss_peak_mb ?? status.rss_mb" in builder
assert 'return "";' in builder, "null renders NOTHING"
assert "` · peak ${peak} MB`" in builder, "the clause's exact shape"
success = _body(js, "applySyncSuccess")
assert (
"setSyncProgress(fmtSyncResult(status.detail) + fmtSyncPeakClause(status))"
in success
)
cancelled = _body(js, "applySyncCancelled")
assert 'setSyncProgress("Sync cancelled" + fmtSyncPeakClause(status))' in cancelled
for untouched in (
"enterSyncRunningState",
"fmtSyncPhaseLabel",
"fmtSyncLabel",
"fmtSyncResult",
"setSyncLabel",
"setSyncProgress",
"applySyncFailure",
"applySyncIdle",
):
assert "fmtSyncPeakClause" not in _body(js, untouched), (
f"{untouched} must not grow a clause — the terminal result "
"line is the only surface (D4)"
)
html = _text(SHELL_HTML)
assert '<span class="sync-progress-text" id="sync-progress-text"></span>' in html
assert 'id="sync-progress-peak"' not in html, "no new element for the clause"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_sources_js_peak_clause_renders_under_node() -> None:
"""The REAL extracted ``fmtSyncResult`` + ``fmtSyncPeakClause`` under
node, composed exactly as ``applySyncSuccess`` composes them: the
counts line keeps its exact wording and the clause trails it
(``3 added · 2 unchanged · peak 1936 MB``); a null peak falls back to
the current reading; BOTH null (idle, a pre-phase-187 run, the
upload job, a platform that cannot know) render the counts line with
NO clause — never ``peak null MB``; ``0`` is a VALUE, not missing
(the ``fmt_mb(0) == "0"`` rule, mirrored); and a negative or
non-numeric value renders nothing."""
src = (
_body(_text(SOURCES_JS), "fmtSyncResult")
+ "\n"
+ _body(_text(SOURCES_JS), "fmtSyncPeakClause")
)
driver_expr = (
"const CASES = ["
"{ detail: { added: 3, unchanged: 2 }, rss_mb: 512, rss_peak_mb: 1936 },"
"{ detail: { added: 0, unchanged: 1 }, rss_mb: 512, rss_peak_mb: null },"
"{ detail: { added: 0, unchanged: 1 }, rss_mb: null, rss_peak_mb: null },"
"{ detail: { added: 1 } },"
"{ detail: { added: 2, pruned: 4 }, rss_mb: 0, rss_peak_mb: 0 },"
"{ detail: { added: 1 }, rss_peak_mb: -5 },"
"{ detail: { added: 1 }, rss_peak_mb: 'big' },"
"];\n"
"const LINES = CASES.map("
" (s) => fmtSyncResult(s.detail) + fmtSyncPeakClause(s)\n"
");\n"
"const CANCELLED = CASES.map("
" (s) => 'Sync cancelled' + fmtSyncPeakClause(s)\n"
");\n"
"console.log(JSON.stringify({ LINES, CANCELLED }));"
)
proc = subprocess.run(
["node", "-e", f"{src}\n{driver_expr}"], capture_output=True, text=True, timeout=60
)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
out = json.loads(proc.stdout)
assert out["LINES"] == [
"3 added · 2 unchanged · peak 1936 MB",
"0 added · 1 unchanged · peak 512 MB", # the current-reading fallback
"0 added · 1 unchanged", # both null → no clause at all
"1 added", # idle / pre-phase / upload status: no keys, no clause
"2 added · 4 pruned · peak 0 MB", # 0 is a value, not missing
"1 added", # a negative reading is not a number to show
"1 added", # garbage is not a number either
], out
assert out["CANCELLED"] == [
"Sync cancelled · peak 1936 MB",
"Sync cancelled · peak 512 MB",
"Sync cancelled",
"Sync cancelled",
"Sync cancelled · peak 0 MB",
"Sync cancelled",
"Sync cancelled",
], out