Files
brain-of-reese/.agents/phases/complete/53_stale_saved_chats/00_phase.md
T
ducoterra dbf2af26c6 refactor(agents): migrate .agent/ planning tree to .agents/
Standardize on the .agents/ directory (shared with project skills):
phases/, user_stories/, reports/, screenshots/, validate.sh, and
phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves
history; runtime artifacts move alongside).

Updates every reference in AGENTS.md, README.md, .gitignore, app
docstrings, and test story headers. Historical KB content in data/
and the runtime pipeline.log transcript are left untouched.
2026-09-05 10:57:07 -04:00

7.2 KiB

Phase 53 — Invalidate Saved Chats on Sources Sync

Source: TODO.md L4 — "Make sure the saved chats are invalidated if the docs are synced, that way it generates a new answer with new data" Story: n/a (TODO-derived — owner instruction 2026-08-30: convert without confirmation) Context: Phase 50 stores explicitly saved conversations in saved_chats (JSONB bor.chat.v1 records; admin-only CRUD under /api/chats in app/api/chats.py; the History page is a full-width table per AGENTS.md rule 5; /?chat=<id> re-opens a row pixel-identical through the phase-14 restore path). Phase 51 added the public snapshot read (/shared/<token> → SharedChatOut, no admin dependency). Sources are synced through two canonical paths: the admin Sync button (POST /api/sync → app/api/sync.py::_run_sync: check_models → resolve effective sources → clone/pull or local-dir re-verify → import_sources(prune=True) → change-gated regenerate_overview) and the CLI/quadlet scripts/import_docs.py (same import_sources; --limit debug runs and unchanged re-imports are change-gated on added + updated). Neither path records when the KB last changed, so a saved answer can silently predate the current index. retryLastTurn(wrap) in frontend/assets/app.js (phase 49) is the existing redo-in-place mechanism for the LAST brain bubble (re-asks the preceding user question, runTurn(text, { reask: true }), no user append, no scroll) — the Regenerate action reuses it. Single-row table precedent: kb_overview (id = 1, app/models.py::KbOverview).

Objective

Every sync that actually changes the knowledge base bumps a sources version; saved chats stamp that version at save time; a chat saved against an older version is surfaced as stale (History table badge + a banner when opened) with a Regenerate action that re-asks the last question against the new index and re-saves the row — a stale answer can no longer masquerade as current.

Dependencies

  • 50_chat_history (complete) — the saved_chats row, /api/chats CRUD, the ?chat=<id> boot load, the linked-row Save upsert (saveCurrentChat in app.js).
  • 51_share_chat (complete) — the public /shared/<token> snapshot read (stays a frozen snapshot; untouched by this phase).
  • 49_retry_answer (complete) — the retryLastTurn redo-in-place the Regenerate action drives.
  • 52_pinned_composer (todo, preceding) — the chat-page layout is stable while the banner lands; ordering keeps the chat UI quiet (no shared-file conflict beyond styles.css/app.js regions).

Tasks

  1. 01_sources_version_and_migration.md — the single-row sources_meta table + saved_chats.sources_version (migration 0010) + the current/bump helpers + the migration test.
  2. 02_sync_version_bump.md — the version bump on both sync paths (Sync button + CLI), change-gated.
  3. 03_chats_api_staleness.md — stamp-on-save + the stale flag on the list/detail responses.
  4. 04_history_stale_badge.md — the Stale column on the History table.
  5. 05_stale_banner_and_regenerate.md — the chat-page banner + Regenerate (retryLastTurn reuse) + the auto re-save.
  6. 06_e2e_stale_saved_chats.md — the story Playwright suite + regressions + commit.

Testing & Quality

  • Unit: tests/unit/test_sources_meta.py (helpers: absent row → 0, first bump → 1, second bump → 2, idempotent reads); the sync bump gates — extend the existing sync coverage (tests/integration/test_sync_api.py + the tests/fakes.py override patterns) and the CLI coverage (tests/integration/test_import_docs_overview.py pins the change-gating pattern; the bump asserts sit alongside).
  • Integration: tests/integration/test_migration_0010.py (house pattern, from test_migration_0009.py); extend tests/integration/test_chats_api.py (stamp + stale flag + share-unshare version immunity).
  • Frontend source pins (house style): history.js stale-cell branch, app.js banner reveal / Regenerate wiring / post-regenerate persist / no-brain-bubble guard (the test_history_page.py / test_save_chat_ui.py pin patterns).
  • Coverage: >90% on app/ (validate.sh gate).
  • E2E (mandatory, A16): tests/e2e/test_stale_saved_chats.py, run in isolation.

Completion Criteria

  • A KB-changing sync (button or CLI) bumps sources_meta.version exactly once; an unchanged re-run, a --limit debug run, and a FAILED sync never bump.
  • A Save/Re-Save stamps the row's sources_version; GET /api/chats + GET /api/chats/<id> expose stale (true iff the row's version is behind the current one); share/unshare never touch the version.
  • The History table shows the Stale marker exactly on rows saved before the last KB-changing sync (full-width table geometry unchanged, AGENTS.md rule 5).
  • Opening a stale /?chat=<id> shows the banner; Regenerate re-streams the last answer in place against the new index and re-saves the row — the banner clears, GET /api/chats/<id> reports stale: false, the History marker is gone.
  • A /shared/<token> page is unchanged — the public snapshot carries no staleness surface.
  • uv run pytest green; coverage TOTAL >90%.
  • uv run pytest tests/e2e/test_stale_saved_chats.py -v --no-cov green in isolation (DB up).
  • Regression E2E suites green in isolation: test_chat_history.py, test_share_chat.py, test_sync_button.py, test_retry_answer.py, test_chat_persistence.py.
  • uv run ruff check . && uv run pyright clean.
  • One --no-gpg-sign commit; phase dir moved to .agents/phases/complete/.

Locked decisions

  • Recorded assumptions (TODO conversion, 2026-08-30 — owner asked for no confirmation):
    1. The invalidation marker is a monotonically increasing sources_meta.version (single row, the kb_overview id=1 precedent), bumped ONLY when a sync changed the KB — the gate is added + updated + pruned > 0 (pruned counts: a deleted doc can invalidate an answer that cited it — deliberately broader than the overview gate's added + updated > 0).
    2. stale = row.sources_version < current, computed server-side in the chats API; the client never computes staleness. Pre-existing rows stamp 0 (the pre-counter KB) and go stale on the first bump.
    3. Regenerate = the phase-49 redo-in-place of the LAST brain bubble (re-ask the last user question with the full conversation context) followed by an auto re-save of the linked row — the owner does not press Save again; a stale chat with no brain answer shows the banner text without a Regenerate button.
    4. The public shared snapshot (/shared/<token>) is deliberately untouched — a frozen snapshot by design; an owner who regenerates can re-share afterwards.
    5. A failed sync (git/embed/import error) aborts BEFORE the bump — a failed sync never invalidates chats; the bump commits even if the best-effort overview regeneration then fails (the index really did change).
  • A10 honoured — /api/chat stays stateless; staleness is a property of the explicitly saved row, not of the chat endpoint.
  • A16/A17 honoured — one story E2E suite, one atomic commit.

Commit

git add -A .agents/ app/ alembic/versions/ scripts/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index"