phase: 189_chat_save_long_turns
Build and Push Containers / build-and-push-app (push) Successful in 3m4s
Build and Push Containers / build-and-push-db (push) Successful in 12s

**Phase 189 final verification — all green**

- **Verified (all 4 tasks already implemented):** A1 caps in `app/schemas.py` — `sources`/`related`/`tools` 200, `suggestions` 100, `segments` 450; `text`/`thinking` 600 000, `error`/`image` 500, `answer_text` 50, `messages` 500, 8 M budget all UNCHANGED; rationale docstrings rewritten to `merge_cited_docs`/`agent_max_rounds`. A2 `shareCurrentChat` linked branch upserts (PUT, 404→create+re-link) before `POST /{id}/share`, aborts on failure (banner, no token); unlinked branch byte-identical. A3 `savePending` + `finally` re-run. A4 `saveFailedSticky` + one 5 s `setTimeout` retry on network/5xx only, 4xx never retries.
- `uv run pytest --cov=app --cov-report=term-missing` → **4609 passed, 1 warning, EXIT=0**, `TOTAL 99%`, `app/schemas.py 100%` (>90%).
- `uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov` → **4 passed, EXIT=0** (isolation).
- Regression green & **unedited** (`git status` clean): `test_chat_save_payload_limits.py` 2, `test_chat_history.py` 5, `test_share_chat.py` 4. Extra blast-radius sweep (share/save/persist/segments surface, all EXIT=0): `test_save_share_ux`, `test_stale_saved_chats`, `test_history_pagination`, `test_history_copy`, `test_bottom_chat_actions`, `test_reasoning_bubble`, `test_tool_budget_telemetry`, `test_verbatim_show_text`, `test_smoke`. No leaked `saved_chats` rows (`save-long-%` = 0).
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**.
- **Completion criteria:** caps ✓ · incident payload 22/33/51 → 201/200 anonymously + 201 tools/451 segments/101 suggestions → 422 naming the offender, row unchanged ✓ · share-saves-first + abort + unlinked byte-identical ✓ · overlap re-run + sticky note + one-shot retry ✓ · phase E2E in isolation + 3 regression suites unedited ✓ · full suite / coverage / lint / types ✓ · `git diff --stat` = `app/schemas.py`, `frontend/assets/app.js`, 3 test files, `README.md` + new E2E & phase files (no `app/api/chats.py`, no migration, no new dependency) ✓ · commit + `todo/`→`complete/` move left to the harness (rules forbid git index writes).
- **Docs:** full README review (all 2 258 lines) + machine cross-check — all 85 settings documented in the Configuration reference, all 34 `.env.example` vars documented, documented defaults match `app/config.py`. Fixed 2 inconsistencies: (1) the 117-char prose line task 04 left when reflowing the Saved-chats pager paragraph; (2) the phase-189 sentence implying the 8 M total bounds the widened arrays — now states precisely that the total counts `text`/`thinking`/verbatim-block text only, and that the arrays are bounded by fixed item COUNT (matches `_require_within_total_budget`). AGENTS.md verified consistent — it enumerates no save contract or list cap (rule 5's phase-166 in-flight-turn clause untouched), so **no AGENTS.md change** per the phase mandate.
- **Notable/deviation:** the wider caps raise the *theoretical* anonymous-POST worst case (arrays are outside the 8 M char budget); left as designed (A1 fixes every other bound), documented honestly in README — realistic payloads stay ~10 MB, so the existing `client_max_body_size ≥ 16 MB` guidance still holds. No live-infra changes were made (data-repair of the two stale shared rows stays owner action).
- **Next pending phase:** none — `189_chat_save_long_turns` is the last entry in `.agents/phases/todo/`.
This commit is contained in:
2026-10-17 07:36:28 -04:00
parent f20d40e4fe
commit 140a343b92
36 changed files with 2642 additions and 65 deletions
+116 -13
View File
@@ -174,12 +174,17 @@
* our back) unlinks and retries as a create, so a stale link can never
* wedge the conversation. The module-level `persisting` flag is the
* double-fire guard: the save points can overlap (pagehide during a
* stream), so a call while an upsert is in flight is a no-op — the
* next save point retries. The A2 quiet contract: a FAILED auto-save
* stream), so a call while an upsert is in flight does not start a
* second one — phase 189 (A3) marks it PENDING (`savePending`) and the
* in-flight upsert's finally re-runs it once, so a save point is never
* silently dropped. The A2 quiet contract: a FAILED auto-save
* never blocks the conversation — a one-line #send-status note ("Couldn't
* save automatically — will try on the next message."), NO error banner;
* a SUCCESSFUL auto-save is silent (the History page is the visible
* proof — the toast is reserved for share). The row link survives
* phase 189 (A4) keeps that note until the NEXT SUCCESSFUL save and
* retries ONCE after 5 s on a transient failure (network or 5xx — never
* a 4xx, which is deterministic); a SUCCESSFUL auto-save is silent (the
* History page is the visible proof — the toast is reserved for share).
* The row link survives
* reloads: the bor.chat.v1 record carries `chatId` (null when unlinked;
* a pre-55 record without the field reads as null — never throws), so a
* refresh restores the conversation AND its link. "New chat" unlinks
@@ -3206,19 +3211,41 @@ async function restoreSavedChatFromUrl() {
*
* The `persisting` flag is the double-fire guard: the save points can
* overlap (pagehide during a stream), so a call while an upsert is in
* flight is a no-op — the next save point retries. The A2 quiet contract
* flight never starts a second upsert. Phase 189 (A3, the owner incident
* 2026-10-16) retired the DROP: the overlapping save point sets
* `savePending` and the in-flight upsert's finally re-runs
* persistConversation() once it has fully settled — a fresh, serialized
* call that reads `conversation` at ITS call time, so a done-time save
* dropped by a still-running send-time save lands with the brain record
* instead of vanishing until a save point that may never come (the
* incident row kept only the question). The A2 quiet contract
* on failure (non-ok HTTP or network): a one-line #send-status note,
* NO error banner, the turn never blocks. Success is SILENT (the History
* NO error banner, the turn never blocks. Phase 189 (A4) extends that
* contract's LIFETIME without changing its shape: the note stays until
* the NEXT SUCCESSFUL save (`saveFailedSticky`), and a TRANSIENT failure
* (a network throw or a 5xx) schedules ONE retry after 5 s
* (`retryScheduled` — overlapping failures never stack retries); a 4xx
* never retries, because a 422 is deterministic and task 01's cap realign
* is the remedy. Success is SILENT (the History
* page is the visible proof — the toast is reserved for share), apart
* from clearing the phase-53 stale banner (a successful re-save
* re-stamps the row to the current generation — the row is no longer
* stale). */
* stale) and clearing a stuck failure note. */
let persisting = false; // phase 55: one upsert at a time (double-fire guard)
let savePending = false; // phase 189 (A3): a save point that arrived mid-upsert re-runs
let saveFailedSticky = false; // phase 189 (A4): the failure note stays until a success
let retryScheduled = false; // phase 189 (A4): one transient retry in flight at a time
async function persistConversation() {
if (!conversation.length) return; // nothing to save
if (persisting) return; // an upsert is already in flight (double-fire guard)
// A3 (phase 189): an upsert is already in flight — mark this save point
// PENDING (never drop it) and let the finally below re-run it once.
if (persisting) {
savePending = true;
return;
}
persisting = true;
savePending = false; // this run consumes the pending flag (a re-run is this run)
const body = JSON.stringify({ messages: conversation });
const headers = { "Content-Type": "application/json" };
try {
@@ -3240,6 +3267,18 @@ async function persistConversation() {
// next save point retries.
sendStatus.textContent =
"Couldn't save automatically — will try on the next message.";
// A4 (phase 189): the note is STICKY — it stays until a save
// succeeds — and a 5xx is transient, so it gets ONE retry after 5 s.
// A 4xx never retries: a 422 is deterministic (task 01's cap realign
// is the remedy), and the retry would 422 again.
saveFailedSticky = true;
if (res.status >= 500 && !retryScheduled) {
retryScheduled = true; // one retry at a time — overlapping failures never stack
setTimeout(() => {
retryScheduled = false;
void persistConversation(); // the LATEST conversation, the same guards
}, 5000);
}
return;
}
if (res.status === 201) {
@@ -3250,12 +3289,39 @@ async function persistConversation() {
// current generation (phase 53, task 03): the row is no longer
// stale, so the banner is done.
if (staleBanner) staleBanner.hidden = true;
// A4 (phase 189): a success clears a stuck failure note — the note's
// lifetime is "until the next successful save" (other flows may
// overwrite it meanwhile; the flag + the next save point are the
// guarantee, never a silently-failed save with no trace).
if (saveFailedSticky) {
saveFailedSticky = false;
sendStatus.textContent = "";
}
} catch {
// A2 quiet contract: a network failure is the same one-line note.
sendStatus.textContent =
"Couldn't save automatically — will try on the next message.";
// A4 (phase 189): the catch arm IS the network-failure arm — sticky
// note plus ONE retry after 5 s (the one-shot guard sits BEFORE the
// setTimeout so overlapping failures cannot stack retries).
saveFailedSticky = true;
if (!retryScheduled) {
retryScheduled = true;
setTimeout(() => {
retryScheduled = false;
void persistConversation(); // the LATEST conversation, the same guards
}, 5000);
}
} finally {
persisting = false; // released on EVERY outcome
// A3 (phase 189): a save point that arrived while THIS upsert ran is
// re-run once, after the in-flight request fully settled — never
// concurrent with it, and the flag is cleared first so the re-run
// does not schedule a second one.
if (savePending) {
savePending = false;
void persistConversation();
}
}
}
@@ -3341,7 +3407,20 @@ function showToast(message) {
* banner is the failure UI). 403/5xx → the actionable banner
* (neutral — the write surface is public, phase 55 task 01); a network
* failure → the reachable? banner. The double-click guard releases in
* the finally — never stale (PLAN §7.4). */
* the finally — never stale (PLAN §7.4).
*
* Phase 189 (task 02, A2 — the owner incident 2026-10-16): the LINKED
* branch UPSERTS the current conversation BEFORE minting the token, on
* the same upsert semantics as persistConversation (PUT; a 404 unlinks
* and retries as a create, a 201 re-links). A Share that minted a token
* on a row the conversation had outgrown published a truncated snapshot
* — the incident row held ONLY the question because the done-time
* auto-save had 422'd into the phase-55 quiet failure, and the Share
* then froze that loss behind a link. An upsert FAILURE ABORTS the share
* (the neutral banner, no token, no toast): the owner must never be
* handed a link to content that is not the current conversation. The
* UNLINKED branch is unchanged — its POST { messages, share: true }
* already saves AND shares the CURRENT conversation in one commit. */
async function shareCurrentChat() {
if (!conversation.length) {
sendStatus.textContent = "Nothing to share yet.";
@@ -3352,14 +3431,38 @@ async function shareCurrentChat() {
try {
let shareUrl;
if (currentChatId) {
// Linked (already saved): the idempotent share — an existing
// token comes back unchanged, a new one is minted.
const res = await fetch(`/api/chats/${currentChatId}/share`, { method: "POST" });
// Phase 189 (A2): SAVE FIRST. persistConversation's own upsert —
// PUT the current conversation, a 404 (the row was deleted on the
// History page) unlinks and retries as a create, a 201 re-links.
// Sharing without this would mint the token on whatever the row
// last held, freezing a truncated snapshot behind a link.
const body = JSON.stringify({ messages: conversation });
const headers = { "Content-Type": "application/json" };
let res = await fetch(`/api/chats/${currentChatId}`, { method: "PUT", headers, body });
if (res.status === 404) {
// Stale link: the row is gone — unlink and retry as a create,
// so a Share never dies on (or publishes) a deleted row.
currentChatId = null;
res = await fetch("/api/chats", { method: "POST", headers, body });
}
if (!res.ok) {
// The upsert failed: ABORT the share — no token, no toast, no
// link. The neutral banner is the existing failure UI and the
// finally below still releases the button (never stale).
showErrorBanner("Couldn't share the conversation — try again.");
return;
}
shareUrl = (await res.json()).share_url;
if (res.status === 201) {
currentChatId = String((await res.json()).id); // re-linked to the new row
}
// Saved and current: the idempotent share — an existing
// token comes back unchanged, a new one is minted.
const minted = await fetch(`/api/chats/${currentChatId}/share`, { method: "POST" });
if (!minted.ok) {
showErrorBanner("Couldn't share the conversation — try again.");
return;
}
shareUrl = (await minted.json()).share_url;
} else {
// Unsaved: save AND share in ONE action (owner-locked) — the
// server sets the 128-bit uuid4 token in the same commit.