Skip to content

perf(chat): bound the recurring refreshSlot history fetch to the view's own count - #6947

Merged
chenmingwei23 merged 1 commit into
mainfrom
fix/bound-refresh-slot-4690
Sep 2, 2026
Merged

perf(chat): bound the recurring refreshSlot history fetch to the view's own count#6947
chenmingwei23 merged 1 commit into
mainfrom
fix/bound-refresh-slot-4690

Conversation

@iamwhatever

@iamwhatever iamwhatever commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Problem / Motivation

refreshSlot called fetchSlotDetail(key) with no limit, so every recurring
refresh pulled the whole chained transcript. It fires on a WS reconnect, on every
chat_done, and on a variant switch — so the cost grows with the transcript and is
paid again at the end of each turn. A user with a long session re-downloads their
entire history on every reply.

The warmSlotCache half of #4690 is already fixed — merged commit 8c751bda1
(#3240) bounds it to PANE_HYDRATE_LIMIT. This PR closes the remaining half and
leaves the warm path untouched.

Why it matters

The refresh is recurring, not one-shot, so this is the transcript-length tax on
every turn end and every reconnect: a multi-thousand-row session pays it repeatedly
for rows already on screen. It gets worse the longer a session is used, which is the
opposite of what the user experiences elsewhere in the chat.

What changed (motivation → approach → change)

1. The client bound (the fix #4690 asks for)

A fixed bound is not available to this thunk. Unlike a pane warm, refreshSlot
replaces messages in place, so a PANE_HYDRATE_LIMIT page would delete
scrollback the user had paged back through — exactly what the fetchSlotDetail
comment was guarding against. So the bound is count-matched, against the view's
server-row span:

const view = state.messages
const serverRows = view.filter(m => typeof m.meta?.mid === 'string' && m.meta.mid.length > 0)
const held = serverRows.length
const want = Math.max(held, PANE_HYDRATE_LIMIT)
const bounded = held > 0 && want <= REFRESH_LIMIT_CEILING

The handler's slice is the most-recent-N, so a request of that size is bounded (it no
longer grows with the transcript) while never returning fewer rows than are on screen.

The count is the server-row span, not messages.length: the array also carries
client-only rows (a thinking block, a permission card, a queued bubble) that the
server transcript does not, and counting those over-requests past the view's own span.
meta.mid is the server's per-row stamp — the same notion serverRowCount and the
reducer's priorServerRows are built on. PANE_HYDRATE_LIMIT is the floor, not a
cap; a floor cannot truncate.

Two carve-outs, both to avoid trading a perf win for a truncation:

  • Above REFRESH_LIMIT_CEILING (500). The handler clamps limit to 500
    (min(int(limit_raw or "200"), 500)), so a count-matched request above it comes back
    short of what it matched. A view paged back past 500 rows keeps the unbounded shape.
  • A view with a server span of zero. No count to match, and that refresh is the
    client's only read of a transcript the client holds nothing of.

2. The sliding window (matching the count is not matching the rows)

Matching the count preserves the row count, not the row identities. When the
server gained rows while this client was away — precisely the reconnect this refresh
recovers from — the most-recent-N slice begins newer than the view's oldest loaded
row. So the page is checked before it is fulfilled, and is safe on any one of three
counts, each a different relationship between its range and the view's:

  1. it reaches the start of history (!hasMore), so it covers the view whatever the
    identities are;
  2. it contains the view's oldest row, so it spans everything the view holds — where
    the floor's over-request lands, and a superset can lose nothing;
  3. its own oldest row is in the view, so the ranges overlap and
    olderHeadAbovePage can cut a head to keep above it.

On none of the three, page and view are fully disjoint and the fetch is retried
unbounded. I deliberately did not reuse warmSlotCache's disjoint-and-behind branch
([...prior, ...pageTail]): that is right for a per-slot cache but wrong for the active
transcript, where it would publish rows 120–299 followed by 320–499 with 300–319
silently missing.

For the overlapping case, refreshSlot.fulfilled keeps that head through the same three
shared helpers switchSlot/warmSlotCache already use — olderHeadAbovePage,
serverRowCount, pagingCursorAfterKeptHead. That cut exists precisely so a reducer
consuming a fetchSlotDetail page does not re-derive it; re-deriving is how the first
two diverged. The kept head also shifts the older cursor (so "load earlier" is not a
dead click), and windowComplete for both reasoning helpers now describes the loaded
window
rather than the fetch — it defaulted to true, a claim a bounded page cannot
make.

3. The server-side gap this exposes — filed, not fixed here

Bounding this fetch moves it onto the other branch of the slot-detail handler, and
the two branches do not agree about which store decides a row's content:

Branch Reads Authority for content
unbounded older + list(slot.messages) the in-memory window
bounded chained disk history disk

That disagreement is pre-existing on main and already reachable through the
pane-hydrate and warm-cache paths. This PR does not introduce it; by bounding the
recurring refresh it makes it more frequent.

An earlier revision of this PR carried a server-side reconciliation for it. It drew four
blocking findings in one span across four rounds, and the last three each came out of the
previous round's fix — matching rows after the fact always has a case where the match is
wrong. On the fourth, the reviewing lane recommended removing the mechanism rather than
refining it, and that is what happened: chat_handlers.py is byte-identical to main on
this branch.

The gap, all four findings, the round-by-round table, and two directions that remove the
ambiguity by construction are in
#7526. The eleven tests written
across those rounds are preserved in commit c9979c43dff6c8699735802b64d946a913433fed
for whoever picks it up.

Tests

Frontendwebsite/src/store/chatSlice.refreshSlotBound.test.ts, 20 tests. They
assert the limit argument reaching api.chatSlotDetail, not merely the resulting
state: the argument is the fix, and a state-only assertion would still pass with the
bound removed. The mock mirrors the handler — collapse before slice, most-recent-N, and
the 500 clamp.

The bound itself: a recurring refresh sends a limit and it is not the corpus size; a
3-row slot asks for PANE_HYDRATE_LIMIT; a view paged back to 180 refreshes at 180,
not 50
; hasMore and slotOldestIndex are unchanged for a paged-back view; a
refresh covering the corpus reports hasMore: false; a view above the ceiling stays
unbounded; a zero-server-span view stays unbounded; a streaming view is
count-matched and loses no rows; a non-active slot fetches nothing.

A slid window must not drop the head: the server gaining 5 rows keeps the loaded oldest
rows; the kept head shifts the older cursor; a head proving completeness reports
hasMore: false; a gap sliding the page clear of the view retries unbounded; a view
with no server identity is not bounded at all; a page already at the start of history
does not retry; an id-less legacy prefix below the oldest identified row survives
(all 200 rows, {hasMore: false, oldest: 0}); a mixed-history page sliding clear retries
unbounded with all 400 rows retained.

The count is the view's server-row span: client-only rows are not counted (a 192-row
view holding 12 reasoning rows requests 180); a window holding reasoning makes
one request, not a bounded one plus an unbounded retry; the floor over-requesting
into a superset does not retry.

Red-before: 7 of the 20 frontend tests fail on the base commit, and the three
head-keeping cases fail with the count-matched bound in place but their own guard
reverted, so each guard is pinned independently rather than by the bound alone.

Also re-ran the neighbouring suites: the chatSlice family (8 files, 528 tests) —
including chatSlice.boundedRefetchShrink.test.ts and chatSlice.warmSlotCacheBound.test.ts,
which pin the anti-shrink contract this PR operates inside — and, to confirm this branch
leaves the server untouched, the three slot_detail backend suites against main's own
handler (23 tests, all passing). No existing assertion was edited or deleted.

Manual verification

N/A — unit coverage sufficient. The change is exercised directly: the limit argument on
an HTTP call plus the reducer's merge of its response. It has no rendered surface to drive
that the tests do not already cover.

Screenshots / video

Why no screenshot: store-only change — no component, layout, theme, or user-visible
string is touched, so a before/after would be identical pixels.

Related Issues

Fixes #4690

@iamwhatever
iamwhatever requested a review from a team August 30, 2026 06:34
@iamwhatever
iamwhatever requested a review from a team as a code owner August 30, 2026 06:34
@iamwhatever
iamwhatever requested a review from dwu96 August 30, 2026 06:34
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 30, 2026
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of b4008212d18a8ec66d63d153683b8859a311d69a — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

UX-Verdict: PASS

Store-only perf change with no rendered surface: no strings, components, or pixels touched, and the diff's guards plus tests preserve the visible transcript, scrollback, and "load earlier" behavior exactly.

[UX-REVIEWED] b400821

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ human override accepted

Human judgment by @iamwhatever overrides the GPT 5.6 finding for b4008212d18a8ec66d63d153683b8859a311d69a; the recorded reason is authoritative for this commit.

This comment is updated in place on each push.

The model was not re-run because an authorized human decision supersedes it.

False positive or not applicable? A repository writer can comment:
/ai-review override gpt b4008212d18a8ec66d63d153683b8859a311d69a: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 30, 2026
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of b4008212d18a8ec66d63d153683b8859a311d69a — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound, safety-first bound — but it expires at the 500-row ceiling, so the longest sittings quietly regain the exact per-turn tax this PR removes.

Watch

  • The bound self-disables mid-session: refresh replaces messages with a held-sized page, so a dashboard left open accumulates held (~2 rows/turn) until want <= REFRESH_LIMIT_CEILING fails — "a view paged back past 500 rows keeps the unbounded shape" — and every subsequent chat_done is back to the full-transcript fetch, precisely for the heaviest users the motivation names. The description frames the carve-out as paged-back views only; natural accumulation reaches it too.
  • Bounding moves every turn-end refresh onto the disk-authority branch of the slot-detail handler ("by bounding the recurring refresh it makes it more frequent"), so the disk/window content disagreement in Bounded slot-detail read cannot reconcile disk history with the live slot window #7526 goes from occasionally reachable to hot-path. Confirm you're comfortable shipping the frequency shift before Bounded slot-detail read cannot reconcile disk history with the live slot window #7526 lands.

Suggestions

  • Cap instead of abandoning: want = min(max(held, floor), 500) — the head-keep + overlapsView machinery this PR already builds handles a short page (kept head, shifted cursor, unbounded refetch on anchor decline), so views past the ceiling could stay bounded rather than reverting.

[DESIGN-REVIEWED] b400821

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of b4008212d18a8ec66d63d153683b8859a311d69a — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All claims verified against the repo: the server clamp is real (chat_handlers.py:1753), meta.mid is genuinely caller-supplied and minted only when absent (state.py:3919), the refresh genuinely recurs (3 call sites in useWebSocket.ts), and the shared helpers are reused rather than re-derived. Two things a human should see: the shared-cut hardening also changes warm/switch behavior, and two unbounded siblings remain.

First-Principles-Verdict: CONCERNS

The bound is derived and count-matched correctly, but a shared-cut correctness fix rides along in a perf PR and two unbounded siblings stay unfixed.

What this change ships

Intent: stop the recurring chat refresh from re-downloading the whole transcript every turn end and reconnect — a FIX.

  1. Recurring refresh now asks for the view's own row count, not everything — justified
  2. Views paged past 500 rows keep the unbounded refresh — justified (server clamp, chat_handlers.py:1753)
  3. Empty or id-less views keep the unbounded refresh — justified
  4. A slid/ambiguous page triggers one unbounded retry — justified fallback branch
  5. Refresh reducer now keeps paged-in scrollback and shifts the "load older" cursor — justified, reuses shared helpers
  6. Parked reasoning re-seats against the loaded window, not the fetch — consequence of 5
  7. olderHeadAbovePage now declines a duplicated meta.mid on ALL three paths — rides along
  8. CLIENT_ONLY_ROLES consolidation of the durable-role triple — justified deletion of a second spelling
  9. Exported REFRESH_LIMIT_CEILING — zero non-test importers, in-file precedent (PANE_HYDRATE_LIMIT)

Watch

  • Item 7 changes switchSlot/warmSlotCache behavior too (3 callers of olderHeadAbovePage: chatSlice.ts:5096, 5264, 5359) in a PR framed as "perf(chat)". Its zero option is not free — mid duplicates are possible (state.py:3919), so a bare findIndex cut drops rows — but it is a separable correctness fix a human should see named.
  • 2 counted siblings of the same root cause stay unbounded (grepped fetchSlotDetail(): chatSlice.ts:1661 and chatSlice.ts:2458 fetch unbounded whenever streaming || cached > 0. The description declares only the streaming leg deliberate; the cached > 0 leg pays the same transcript-length cost per slot switch and could count-match against the cache with the mechanism this PR just built. Accepted-and-deferred, not a demand.
  • The nameable deeper cause — the handler's unbounded default when limit_raw is None (chat_handlers.py:1771) — is out of scope here because the deliberate unbounded consumers depend on it; the description says so and files Bounded slot-detail read cannot reconcile disk history with the live slot window #7526. Honest framing.

[FIRST-PRINCIPLES-REVIEWED] b400821

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed b4008212d18a8ec66d63d153683b8859a311d69a — this comment is updated in place on each push.

Review details

Based on my independent review of the diff and the shipping logic in chatSlice.ts, I verified the count-matched bound, the floor guard, the mid-await re-read, the spansView/overlapsView/!hasMore fallback triad, the idAnchorsOneRow strict-vs-lenient split, the olderHeadAbovePage no-duplicate boundary, and the keptCursor-driven windowComplete tightening. Each holds up: declines fall to the safe direction (extra round trip or kept head), the head concatenation cannot duplicate the boundary row, and the client-only-row exclusion prevents the floor-guard bypass. No candidate was supplied to falsify, and no independently grounded defect meets the (a)/(b)/(c) bar.

No findings.

[OPUS-REVIEWED] b400821

Verdict parsed from the review's SHA-scoped output markers for commit b4008212d18a8ec66d63d153683b8859a311d69a.

False positive or not applicable? A repository writer can comment:
/ai-review override fable b4008212d18a8ec66d63d153683b8859a311d69a: <one-sentence reason>

@iamwhatever
iamwhatever force-pushed the fix/bound-refresh-slot-4690 branch from 9f85889 to c257152 Compare August 30, 2026 07:14
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 30, 2026
@iamwhatever

iamwhatever commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author
  • fixed — span=52a4927c3548 — website/src/store/chatSlice.ts — Reconnect refresh discards loaded scrollback after missed messages

Missed server messages -> reconnect requests latest held rows -> reducer replaces the transcript, dropping previously loaded oldest rows.
Fix: Restore the unbounded fetchSlotDetail(key) call.

The mechanism holds and the finding is accepted. Matching the count preserves the row
COUNT, not the row IDENTITIES: when the server grew during the gap, the most-recent-N
slice begins newer than the view's oldest loaded row, so the in-place replacement drops
that scrollback. The reasoning behind the original bound ("can never return fewer rows
than the view holds") was about count and was wrong about identity.

The suggested remedy — restoring the unbounded call — is the only part not taken, since
it reverts the issue this PR closes rather than fixing the mechanism. Fixed instead by
checking the page BEFORE it is fulfilled, in 740f74515621. The page is safe on any one
of three counts, each a different relationship between its range and the view's: it
reaches the start of history; it contains the view's oldest row (a superset loses
nothing); or its own oldest row is in the view, so olderHeadAbovePage can cut a head
to keep above it. On none of the three, page and view are fully disjoint and the fetch
is retried unbounded. Splicing a disjoint page onto the view was rejected as the
alternative: it publishes a transcript with a silent hole in it.

For the overlapping case, refreshSlot.fulfilled now keeps that head through the three
helpers switchSlot.fulfilled and warmSlotCache.fulfilled already share —
olderHeadAbovePage, serverRowCount, pagingCursorAfterKeptHead. That cut's own
contract is that every reducer consuming a fetchSlotDetail page routes through it
rather than re-deriving it, so this is the sanctioned mechanism for a bounded page and
not a new one. The kept head also shifts the older cursor, and windowComplete for both
reasoning helpers now describes the loaded window rather than the fetch.

Regression coverage in website/src/store/chatSlice.refreshSlotBound.test.ts, asserting
the limit argument reaching api.chatSlotDetail rather than only the resulting state:
keeps the loaded oldest rows when the server gained messages during the gap; shifts the older cursor by the head it kept, so "load older" is not a dead click; reports no older page when the kept head proves the window is complete; refetches unbounded when the gap slid the page CLEAR of the view; does not bound at all when the view carries no server row identity; does NOT retry when the page already reaches the start of history. The three head-keeping cases fail with the count-matched bound in place but the
head-keeping reverted, so the guard is pinned independently of the bound rather than
passing merely because the bound is present.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 30, 2026
@iamwhatever
iamwhatever force-pushed the fix/bound-refresh-slot-4690 branch from c257152 to 740f745 Compare August 30, 2026 07:37
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: passed Eligible automated validation passed for the current revision labels Aug 30, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • fixed — span=fccbdb807220 — website/src/store/chatSlice.ts — the count-matched bound is defeated whenever the loaded window holds a client-only row

const held = view.length counts client-only rows (content-bearing thinking, permission, queued) that state.messages carries but the server transcript does not, so want = Math.max(held, PANE_HYDRATE_LIMIT) over-reaches the view's server-row span; the handler returns a most-recent-N page whose oldest row is older than the view's oldest, anchorMid is absent from view, and with page.hasMore true on any transcript longer than the window the thunk falls to unbounded fetchSlotDetail(key)
Fix: derive held from the count of meta.mid-bearing server rows in view (the same server-row notion serverRowCount/priorServerRows already use), not view.length

Accepted and fixed in 740f74515621. Filed as advisory, but taken as in-scope: the
consequence chain lands on this PR's own stated purpose — a session holding a single
reasoning block would have made both a bounded request and the whole-transcript pull
#4690 exists to remove, so the bound would have been strictly worse than the code it
replaced on the most ordinary transcript there is.

held is now the count of meta.mid-bearing rows, the same server-row notion
serverRowCount and priorServerRows are built on, so want matches the view's server
span rather than its array length.

One correction beyond the suggested fix, because the server-row count alone does not
close it: the safety test itself was too strict. It asked whether the page's oldest row
is in the view, which is false in the benign case where the page extends below the view
— exactly what the PANE_HYDRATE_LIMIT floor produces for a near-empty view against a
long transcript, and a superset can lose nothing. Left as-is, a 3-row view would still
have retried unbounded on every refresh. The page is now safe on any one of three counts:
it reaches the start of history; it contains the view's oldest row; or its own oldest row
is in the view (so a head can be cut). Only a page that is fully disjoint from the view
triggers the unbounded refetch.

Coverage, in website/src/store/chatSlice.refreshSlotBound.test.ts under the count is the view's SERVER-row span: does not count client-only rows toward the limit (a
192-row view with 12 reasoning rows requests 180, not 192); makes ONE request for a window holding reasoning, not a bounded one plus an unbounded retry (asserts the call
count and that no undefined limit appears); and does NOT retry when the floor over-requests into a SUPERSET of the view, which pins the second correction.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention readiness: passed Eligible automated validation passed for the current revision labels Aug 30, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • fixed — First Principles 🟡 CONCERNS — the description's code snippet contradicts the shipped hunk, and its test table is short

Item 7 is part of the fix and is tested (3 tests the description's "15 tests" table omits — the file has 18), but the description's quoted snippet (const held = view.length) contradicts the shipped hunk (serverRows.length). Update the description so the record matches what merged.

Correct, and my fault: the body was written for the second revision and not reconciled
after the amend that introduced the server-row-span count (which came from the Opus
lane's advisory finding, span fccbdb807220). The description is now updated — the
snippet is the shipped serverRows filter, item 7 is called out in prose as its own
paragraph rather than left implicit, and the test table lists all 18 tests in the three
groups the file actually has, including the three that pin the server-span count.

On the second Watch item — the REFRESH_LIMIT_CEILING = 500 / chat_handlers.py:1721
coupling — a human confirmation is welcome, but the specific harm named there is already
prevented, and by a different mechanism than the ceiling:

If the server clamp is ever lowered, requests between the two values come back short and shrink the view — the exact harm item 2 exists to prevent.

A short page does not shrink the view, because nothing downstream trusts the ceiling to
have been honoured. Take a server clamp lowered to 200 with a view holding 300 server
rows: want is 300, so the request is bounded; the handler returns the most recent 200.
The view's oldest row is then absent from the page (safety count 2 fails), but the page's
own oldest row is in the view (safety count 3 holds), so the page is accepted and
refreshSlot.fulfilled keeps the 100-row head above it through olderHeadAbovePage
the view stays at 300 rows and the cursor shifts to match. The page-safety check does not
ask why a page begins newer than the view's oldest row, so a lowered clamp reads
exactly like a window that slid during a disconnect, which is the case it was built for.

So the ceiling is an optimization, not the shrink protection: honouring it means the
common case takes one bounded request instead of a bounded request plus a retry. A stale
ceiling costs a kept head or one extra round trip, never rows. That is why I did not add
a client/server assertion or plumb the clamp through an endpoint — it would couple two
files to protect an invariant the reducer already enforces locally, and the constant's
own docstring names the handler it mirrors so a future reader has the pointer.

The SERVER_CLAMP literal in the test is deliberately a second, independent mirror
rather than an import of REFRESH_LIMIT_CEILING: the test's job is to make the handler's
real clamping behaviour observable, so a test that imported the production constant would
move in lockstep with it and could no longer catch the two drifting apart. It is asserted
equal to REFRESH_LIMIT_CEILING in the ceiling test, so a change to one without the
other fails there.

@iamwhatever
iamwhatever force-pushed the fix/bound-refresh-slot-4690 branch from 4481d04 to 05ad4f9 Compare August 31, 2026 18:46
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • fixed — BLOCKING, website/src/store/chatSlice.ts:2284, mixed legacy history can still lose scrollback span=52a4927c3548 — fixed in 05ad4f9233ea53da3ad4e7d9c50ece7be5c8bf60.

Many id-less legacy rows plus a few identified rows -> refresh requests the 50-row
floor -> spansView accepts a page whose oldest row is id-less -> reducer cannot
retain the omitted head and deletes visible history.

The finding holds and is fixed. Working through it narrowed the mechanism, and the
narrowing is what the fix is built on, so it is worth stating precisely.

The reachable path is the floor, not the count-match. At want === held the
loss cannot occur: spansView passes only when all held identified rows sit
inside a page of exactly held rows, which leaves no room for an unidentified row
to be the page's oldest — and overlapsView already requires the page's oldest row
to anchor. So a count-matched request is safe whatever the identities are.

want > held breaks that arithmetic, which is exactly the shape named above: with
20 identified rows the floor asks for 50, so the page holds all 20 plus 30 older
unidentified rows. spansView passes on the oldest identified row while the page's
own oldest row carries no mid, so the mid-keyed cut anchors nothing, the reducer
keeps no head, and the rows above the page are in no page and no head.

The guard is therefore on the floor: a bounded refresh declines when the floor
over-requests and the loaded window holds a durable row with no mid. It only
ever fetches more, so it introduces no new loss path. Modern transcripts keep the
bound — every live session's rows carry a mid, and the live session is the
recurring cost #4690 is about.

Durable is defined once. CLIENT_ONLY_ROLES now backs both serverRowCount, which
counts durable rows to shift a paging offset, and hasUnidentifiedDurableRow, which
looks for a durable row the bound cannot see. A second copy would let them drift,
and a role missing from either would be silent: a mis-shifted cursor in the first,
dropped scrollback in the second. thinking, queued, streaming and permission
are client-only and so do not trip the guard — if they did, #4690 would be unfixed
in every live session. error and mcp_oauth stay absent from the set because they
are persisted.

Four tests, appended, no existing assertion touched. Two pin the decline — the
20-identified floor case keeps all 300 rows, and a single unidentified durable row
declines just as a large legacy block does, since a threshold would leave exactly
that row droppable. Two pin the non-regression: a 180-row identified span still
bounds at 180 with legacy rows present, and a view whose only id-less rows are
thinking/queued still bounds at the floor. Red-before proven — with the guard
disabled and nothing else changed, exactly the two decline tests fail on the
transcript length; the two non-regression tests pass either way, which is what makes
them a guard against over-correction.

Verified on 05ad4f923: 146 store tests green (all 7 chatSlice suites), tsc -b
clean, eslint 0 errors. The earlier blanket form of this guard — decline on any
unidentified durable row, regardless of the floor — is NOT what shipped: it broke
the two existing mixed-history tests at want === held, whose shapes the argument
above shows are safe, so it was replaced by the floor-scoped condition.

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 31, 2026
@iamwhatever
iamwhatever force-pushed the fix/bound-refresh-slot-4690 branch from 05ad4f9 to dee842e Compare August 31, 2026 19:23
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • fixed — BLOCKING, src/kiro_crew/dashboard/chat_handlers.py:1515, reused IDs can substitute an unrelated disk row span=78d6e248f252 — fixed in dee842ef4db366348e5eb1fbbf61a4758455ff18.

Disk-only row plus live caller row sharing meta.mid -> bounded slot refresh ->
persisted content is replaced and the live row appears at the wrong position.

The finding holds, and it corrects something I asserted on an earlier head. A prior
disposition of mine claimed this overlay "matches rows by the same unambiguous-mid
rule, so client and server now decline on the same evidence". That was wrong. The
client's idAnchorsOneRow counts occurrences and then checks the two rows do not
contradict each other
; the overlay only counted. The count is the cheaper half,
and I described the pair as if I had ported both.

Counting once on each side is not identity. The disk read is CHAINED, so it spans
older sessions, and meta.mid is caller-supplied — the backend mints one only when
it is absent. A caller that reuses an id across two sessions therefore produces a
disk row and a window row that are genuinely different messages, each unique on its
own side, which is exactly the shape the count test lets through. The result is not
staleness but corruption: persisted content is overwritten by an unrelated live row.

The fix is the half that was missing. A substitution now also requires the two rows
to corroborate: same role, and the same ts when both carry one.

The ts condition is checked only when both rows have one, which is deliberate and
is the same asymmetry the client draws. Here a decline costs the staleness this
overlay exists to remove, so demanding a ts that legacy rows never carried would
make the overlay dead code on the transcripts that need it most. A missing ts is
not evidence of a mismatch; a differing one is. Content is deliberately not
compared — an in-place variant rewrite changes the body, so body equality would
reject the single case the helper is for.

Three tests appended, no existing assertion touched. A reused id from an older
session at a different ts leaves the persisted row intact; a role mismatch at
one id declines; and a rewrite at a matching ts still substitutes, which is the
non-regression pin that stops the guard from being over-tightened into uselessness.
Red-before proven: with corroboration disabled and nothing else changed, the first
two fail, and the reused-id failure is the corruption itself — the response comes
back ['LIVE ANSWER', 'later question'] with the persisted OLD SESSION ANSWER
gone, not merely mis-ordered.

Verified on dee842ef4: 32 slot-detail tests pass across all four suites, 146 store
tests green, tsc -b clean, eslint 0 errors, black and flake8 clean on both changed
Python files, mypy clean on chat_handlers.py.

The client-side span this lane raised seven times (52a4927c3548) is unchanged by
this round and remains fixed at the shared cut.

@iamwhatever
iamwhatever force-pushed the fix/bound-refresh-slot-4690 branch from dee842e to e76a578 Compare August 31, 2026 19:53
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • fixed — BLOCKING, src/kiro_crew/dashboard/chat_handlers.py:1530, timestamp check rejects the unflushed selected variant span=78d6e248f252 — fixed in e76a578f02b30c94a198c08decbe0ff397c69e37.

Different-timestamp variant + failed save -> switch broadcast -> bounded refresh
repaints stale disk content.

The finding holds, and it is my regression, not reviewer churn. The previous round
of this lane asked for corroboration "with a narrow variant-edit exception". I
implemented the corroboration and omitted the exception, which is precisely the gap
named here.

api_chat_slot_switch_variant assigns target_dict["ts"] = chosen.get("ts", ...),
so a selected variant wears the timestamp of the VARIANT rather than of the row. The
two stores then disagree on ts in the one state the overlay exists for: switch,
inline save fails, chat_variant_switch broadcast, bounded refresh reads disk. A
plain ts equality test declines exactly there and repaints the content the user
just switched away from.

The exception is positive evidence, not a relaxation. Each entry in a row's
variants list is a (content, ts) pair the row has actually worn, so finding one
side's stamp in the other side's list identifies them as one message at two
selections — a stronger signal than the absence of a contradiction, which is all the
ts test ever gave. It is checked in both directions, because which store holds the
fuller list depends on which was written last.

The ordering is what keeps the reused-id fix from being undone: equal ts accepts,
then variant history accepts, and only a differing ts that NEITHER side's variants
account for is a mismatch. A reused id across sessions still has no such account, so
it still declines.

Three tests appended, no existing assertion touched. A ts-rewriting switch keeps
the selected variant; the disk-side-holds-the-history direction does too; and a row
whose variants list exists but does not contain the disk stamp is still refused, so
the exception cannot widen into a blanket pass. Red-before proven: with the variant
clause replaced by return False and nothing else changed, the two accept-direction
tests fail on the stale repaint (['STALE VARIANT'], ['DISK VARIANT']) while the
refusal test passes either way — that asymmetry is what shows the clause fixes the
repaint without reopening the substitution.

Verified on e76a578f0: 35 slot-detail tests pass across all four suites, 146 store
tests green, tsc -b clean, black and flake8 clean on both changed Python files,
mypy clean on chat_handlers.py.

Both findings on this span are now closed, and the arc is worth stating plainly: the
first round's suggestion was correct in full, and shipping half of it produced the
second. The client-side span (52a4927c3548) is untouched by this round.

@iamwhatever
iamwhatever force-pushed the fix/bound-refresh-slot-4690 branch from e76a578 to c07e7fa Compare August 31, 2026 20:24
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • fixed — BLOCKING, website/src/store/chatSlice.ts:2309, permission rows inflate the bound and can delete legacy scrollback span=52a4927c3548 — fixed in c07e7fadbd06a21df81a1f3cfab6443377a214ea.

Mixed legacy history + resolved permission cards -> refreshSlot accepts an
undersized durable page -> visible history is discarded.

The finding holds and the suggested one-line fix is the right one: isDurableRow(m)
is now part of the filter.

Worth being precise about why, because the harm is not the obvious one. An inflated
held over-requests, and over-requesting is safe on its own. The damage is that it
makes want === held true while the DURABLE span is smaller — and want === held is
exactly the condition the floor guard added last round treats as proof of safety. Its
argument is that a page of held rows containing all held identified rows has no
room for an unidentified row to be the page's oldest. Count 30 stamped permission
cards alongside 20 durable rows and held reads 50: the arithmetic still says safe,
while the page of 50 reaches back into legacy rows the mid-keyed cut cannot anchor.
The bypass is the defect; the over-request is a side effect.

So this is the same loss as the previous client-side round, reached through the count
instead of the floor — which is what makes it the eighth finding on this span rather
than a new one. The invariant was already written down; the filter just did not
enforce it. isDurableRow was introduced last round and used in
hasUnidentifiedDurableRow and serverRowCount, and this was the third site that
needed it and did not have it. That is the honest shape of the miss: not a missing
rule, an unapplied one.

Two tests appended, no existing assertion touched. One stamped permission card leaves
the limit at 120 rather than 121, and the 20-durable-plus-30-cards case now declines
to unbounded and keeps all 300 durable rows. Red-before proven: with the
isDurableRow(m) && term removed and nothing else changed, both fail.

Verified on c07e7fadb: 148 store tests green across all 7 chatSlice suites, 35
slot-detail backend tests pass, tsc -b clean, eslint 0 errors.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 31, 2026
@iamwhatever
iamwhatever force-pushed the fix/bound-refresh-slot-4690 branch from c07e7fa to c9979c4 Compare September 1, 2026 01:03
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • fixed — BLOCKING, src/kiro_crew/dashboard/chat_handlers.py:2026, pending rewrites resurrect deleted transcript tails span=78d6e248f252 — fixed in c9979c43dff6c8699735802b64d946a913433fed.

Failed rewrite save -> chat_done bounded refresh -> stale disk-only turns remain
positioned in all_msgs and reappear.

The defect is real and is fixed. The suggested remedy is not what shipped, because
taken literally it would have caused a worse bug — that is worth stating precisely
rather than quietly diverging again.

The diagnosis holds exactly. chat_regenerate truncates the window
(del slot.messages[u_idx + 1:]), sets _pending_rewrite, and only then saves. When
that save fails the two stores disagree about the transcript's LENGTH, not about a
row's content — and _overlay_unflushed_edits cannot reach that: substitution by id
preserves the row count deliberately, which is what keeps total, has_more and the
slice boundary stable. So the deleted turns survive the overlay and come back.

The proposed fix — "use the frozen disk prefix plus the window snapshot as the
corpus" — requires slicing all_msgs at _disk_older_count. That is the one
arithmetic this file already documents as wrong, in _append_unflushed_tail: the
corpus is a CHAINED read spanning older sessions and omitting transient roles, while
that counter describes only the current session's file. The two are different units,
so the slice lands inside real older-session history and deletes it. Trading tail
resurrection for scrollback deletion is not a fix, and it is why that helper uses
identity rather than offsets.

So the boundary is found by identity. _drop_rows_the_window_dropped takes the first
disk row whose meta.mid the window still holds as the start of the window's region;
everything above it is the frozen prefix and is never touched. Inside that region a
row is dropped only when it CARRIES a mid the window does not hold. Both halves are
load-bearing: carrying an id is what marks a disk row as a flushed window row, so an
id-less row there is history this helper has no standing to judge and passes through,
and a window sharing no id with disk yields no boundary at all, so nothing is dropped
and the caller falls through to the existing reconciliation.

It runs before the other two helpers and only under the flag. Length is settled
first, because both _append_unflushed_tail and _overlay_unflushed_edits preserve
or grow the corpus and so neither can retire a turn. The flag is read on the loop
beside the snapshot so it and the window agree.

Five tests appended, no existing assertion touched. The deleted tail does not
reappear; older-session rows above the window are kept, which is the specific
regression an offset-based fix would have caused and is pinned so it cannot be
reintroduced; an id-less row inside the window's region is kept; and the two
no-boundary cases — flag clear, and a window sharing no id with disk — drop nothing.
Red-before proven: with the gate disabled and nothing else changed, exactly the three
drop-asserting tests fail on the resurrected tail (['m0', 'LEGACY', 'm1', 'm6'])
while both no-drop pins pass either way, which is the asymmetry showing the drop
closes the resurrection without over-reaching.

Verified on c9979c43d: 80 tests pass across the four slot-detail suites and both
chat_regenerate suites, 148 store tests green, tsc -b clean, eslint 0 errors,
black and flake8 clean on both changed Python files, mypy clean on
chat_handlers.py.

Two notes for whoever merges. This is the third finding on this span, and the
mechanism it lands on — gating the bounded branch on _pending_rewrite — is what the
requester originally specified; applying the overlay unconditionally was my
deviation, and two of the three findings here are consequences of that choice rather
than of the original design. Separately, Backend Tests (3.10, 2) and its
Coverage Gate cascade are inherited, not from this diff: the failure is
test/test_design_tweak_relay_paths.py:692, a websocket relay assertion disjoint
from these five files, which passes 42/42 locally.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • fixed — BLOCKING, src/kiro_crew/dashboard/chat_handlers.py:1651, pending rewrites discard concurrent ID-bearing appends span=78d6e248f252 — fixed in e9c86a29731c6bb2b52dee7771f3bbe316bc8080 by removing the mechanism, as this lane recommended.

Cross-process stamped append absent from the local window -> bounded slot refresh ->
_drop_rows_the_window_dropped removes the acknowledged row from the transcript.

The finding holds and the recommended remedy was taken: the call and the helper are
gone, along with _overlay_unflushed_edits and its tests. chat_handlers.py is byte
-identical to main on this branch, so the flagged code no longer exists rather than
having been narrowed again. This PR now carries only its client-side change.

The finding is correct that the two cases are indistinguishable. A durable injector
passes the window row's own id to ConversationLog.append_append_unflushed_tail
documents exactly that shape — so a stamped foreign append inside the window's region
is a mid the window does not hold, which is the same signal a deleted tail gives.
Nothing available at refresh time separates them, so the drop had no safe form.

Why removal rather than a fifth attempt. This span took four blocking findings across
four rounds, and the last three each came out of the previous round's fix: identity by
count let a reused id substitute an unrelated row; corroborating ts then rejected the
variant switch the overlay existed for, because api_chat_slot_switch_variant assigns
target_dict["ts"] = chosen.get("ts", ...); and the length fix that followed drops
foreign appends. Each mechanism reconciles two stores by matching rows after the fact,
and every such rule has a case where the match is wrong. That is a design problem, not
four separate bugs.

Filed as #7526 with all four
findings, the round-by-round table, and two directions that remove the ambiguity by
construction instead of inferring it — making the bounded branch window-authoritative
for the window's own region, or recording the truncated ids at truncation time so the
length disagreement is exact. The eleven tests written across these rounds are
preserved in c9979c43d for whoever picks it up; all were red-before proven.

One cost stated plainly rather than buried: the staleness this work addressed is
pre-existing on main and reachable today through the pane-hydrate and warm-cache
paths. Bounding the recurring refresh makes it more frequent without introducing it.
That trade is recorded in #7526 so it does not sit silently.

Verified on e9c86a297: 148 store tests green across all 7 chatSlice suites, 23
slot-detail backend tests pass against main's handler, tsc -b clean, eslint 0
errors. Rebased onto c412c2ff9, single commit on base.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Per the PR's own §3, bounding the recurring refresh moves it onto the handler's
disk-reading branch, so the pre-existing window/disk content disagreement now fires
on every chat_done and reconnect instead of occasionally via pane-hydrate/warm —
and because the empty-view and above-ceiling carve-outs stay unbounded, one session's
rows can alternate between the two authorities across refreshes. Merging this before
#7526 lands is a deliberate frequency regression a human should sanction.

Accurate, including the part I had not stated: the carve-outs mean a single session can
alternate between the two authorities across successive refreshes, not merely shift to
disk. The empty-view case and the above-ceiling case both stay unbounded by design, so
the authority a given refresh reads under is a function of the view's shape at that
moment.

This is put to the maintainer rather than resolved here, because it is the one thing in
this PR that no amount of code changes settles. The trade is fixed:

I am not asking for a third option, and I am not filing an issue for this: the question
IS the deliverable. An earlier revision of this PR carried the server-side half, and it
drew four blocking findings in one span across four rounds, with the last three each
produced by the previous round's fix. That is why the halves are now sequenced rather
than shipped together, and why the ordering is a judgement call rather than a defect to
patch.

Everything the client half can do about it is already in: every guard failure fails
safe to the old unbounded fetch, so no path this PR adds can read disk where the
previous code read the window and lose rows for it. The frequency change is the whole of
the exposure.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • rebutted — Design Review 🟡 CONCERNS, watch item 2 — the unbounded-fallback path is silent, so a perf regression on legacy transcripts is undetectable

Every guard failure (duplicate mid, unidentified legacy row under the floor,
disjoint page) silently degrades to the old unbounded fetch — correct for safety, but
it means the perf win can quietly vanish for exactly the long/legacy transcripts
#4690 is about, with nothing observable saying so. A dev-mode counter or log on the
unbounded-fallback path would make regressions here detectable.

The concern is real and the suggestion is a good one. Rebutted as disproportional to
this PR
, not as wrong, and I would rather say that plainly than widen the diff to
close an advisory item.

Three reasons it does not belong here. This PR is three files in one store slice with no
observability surface of its own, so a dev-mode counter means choosing where that surface
lives — a console channel, a dev-only reducer field, or the metrics module — and that
choice outlives this PR. The silence is also not new: the pre-bound code took the
unbounded path unconditionally, so no measurement regressed, and what is missing is
instrumentation that never existed rather than a guard this PR removed. And the fallback
frequency is a direct function of the disk-vs-window gap in
#7526 — instrumenting it before
that lands measures a shape that is about to change.

What makes it safe to defer is that the fallback is not invisible to a developer who
looks, only to one who does not: each decline is one extra chatSlotDetail request with
limit absent, so the network panel shows the unbounded path directly, and the three
decline conditions each have a named test in
website/src/store/chatSlice.refreshSlotBound.test.ts asserting the undefined limit
argument. So the behaviour is pinned and observable in a dev session; it is aggregate
counting over a real transcript that is missing.

I have added it to #7526 as the observability half rather than filing a separate issue,
since that is where the decision about surface and threshold naturally sits.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • fixed — BLOCKING, website/src/store/chatSlice.ts:2371, anchor validation uses the stale pre-fetch view span=52a4927c3548 — fixed in 7aa96fa1e65884edb05e3f59c95db96bcb74e316.

Repeated meta.mid in an older page -> load-earlier resolves during bounded refresh
-> reducer drops the newly loaded scrollback.

The finding holds and is a genuinely different mechanism from the earlier hits on this
span: not identity ambiguity in a fixed view, but the view MOVING across the await.
view was read before fetchSlotDetail so the limit could be sized from it, and the
anchor checks then reused that same snapshot — so a loadOlderMessages resolving inside
the await was judged against a view that no longer existed.

The decision now re-reads state after the page arrives: viewNow, its serverRows, and
midOccurrences all come from the post-fetch store, so spansView and overlapsView
answer about the view the reducer is about to act on. The limit is deliberately NOT
re-derived — the request is already in flight, and a page that is now too small simply
fails the checks and refetches unbounded, which is the safe direction. A slot switch
during the await returns null, matching the pre-fetch guard. serverRowsNow[0] is
guarded rather than indexed blind, because a clearMessages landing in the await can
empty a view whose pre-fetch held was positive.

One correction to the finding's causal chain, because it changes what the tests can
honestly claim. The plain row-loss case is already covered: refreshSlot.fulfilled
computes its cut from state.messages at reduce time, so prepended rows survive a stale
thunk decision. What the stale read actually breaks is the ambiguity case — rows arriving
mid-fetch can carry a second copy of the anchor's mid, and the old view still reads it
as unique, so the page is accepted and the cut is then made against a duplicated id. That
is the reachable drop, and it is narrower than "load-earlier during refresh" but real.

Four tests appended, no existing assertion touched. Red-before is 1 of 4, and the
asymmetry is the point rather than a gap: with viewNow pointed back at the stale view
and nothing else changed, only refetches unbounded when the arriving rows make the anchor ambiguous fails. The other three are non-regression pins — prepended rows survive
(passing either way, which is what proves the reducer already held that line), an
undisturbed refresh still makes ONE bounded request rather than degrading to a blanket
decline, and a mid-await slot switch retries nothing. I would rather report the 1/4 and
explain it than present four tests as if all four caught the bug.

Verified on 7aa96fa1e: 152 store tests green across all 7 chatSlice suites, tsc -b
clean, eslint 0 errors, single commit on origin/main at 829c0f443.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • rebutted — BLOCKING, website/src/store/chatSlice.ts:2346, bounded variant refresh can repaint stale disk content span=52a4927c3548

Failed variant save -> broadcast refresh -> disk-backed bounded fetch -> selected
variant is replaced by stale persisted content.

The mechanism is real and this PR does not dispute it. What is rebutted is the demanded
fix — Keep refreshSlot unbounded until bounded reads preserve live rewritten rows
which is the whole of what this PR does, so satisfying it means closing the PR rather
than changing a line in it. That is a scope ruling, not a code defect, and it has already
been adjudicated.

This is a repeat, not a new finding: the same lane raised the identical mechanism on
e9c86a297 two rounds ago with the same suggested remedy. Recording the count as this
span's history requires: 52a4927c3548 has now taken eleven findings, nine of them
distinct defects that were fixed in code, and this mechanism twice.

The disagreement is pre-existing on main, not introduced here. The bounded branch reads
chained disk history while the window is authoritative for the current session, and that
is already reachable today through pane-hydrate and warm-cache. This PR does not create
it; by bounding the recurring refresh it makes it more frequent. That is disclosed in
the PR body's own §3, filed with a reproduction and two candidate designs as
#7526, and the merge-ordering
question is already put to the maintainer as an open needs-a-decision on this PR.

Design Review reached the same place independently and landed on 🟡 CONCERNS — advisory,
with "merging this before #7526 lands is a deliberate frequency regression a human should
sanction". Two lanes agreeing that the ordering needs a human is the signal that this is a
decision, not a patch.

Three rounds inside the server half are why it is sequenced rather than shipped together:
that work drew four blocking findings in one span, and the last three were each produced
by the previous round's fix. The lane's own recommendation on the fourth was to remove the
mechanism, which is what happened — chat_handlers.py is byte-identical to main on this
branch.

What the client half does own is fully closed, including this lane's round-10 finding on
the same file: the anchor decision now re-reads state after the fetch, and every guard
failure — ambiguous mid, an unidentified durable row under the floor, a disjoint page, a
view that moved mid-await, a slot switch — falls back to the old unbounded fetch. So
no path this PR adds can read disk where the previous code read the window and lose rows
for it. The frequency change is the entire exposure, and it is the maintainer's call.

Not resolving this thread as fixed, and not asking this lane to change its verdict: the
override, if the maintainer sanctions the ordering, is theirs to post.

…'s own count

`refreshSlot` passed no `limit`, so every recurring refresh -- a WS reconnect,
the one fired on `chat_done`, a variant switch -- pulled the whole chained
transcript. The cost grows with the transcript and is paid again at the end of
every turn.

A FIXED bound is not available to this thunk: unlike a pane warm it REPLACES
`messages` in place, so a 50-row page would delete scrollback the user had paged
back through. That is what the `fetchSlotDetail` comment was guarding.

So the bound is COUNT-MATCHED: ask for at least as many rows as the view already
holds. The count is the view's SERVER-row span (`meta.mid`-bearing rows, the same
notion `serverRowCount` and the reducer's `priorServerRows` are built on), not
`messages.length` -- the array also carries client-only rows (a `thinking` block,
a `permission` card, a `queued` bubble) and counting those inflates the request
past the view's own span. `PANE_HYDRATE_LIMIT` is the FLOOR (a floor cannot
truncate), which covers a fresh or near-empty slot.

Two carve-outs, both to avoid trading a perf win for a truncation:

- Above `REFRESH_LIMIT_CEILING` (500, the ceiling the handler clamps `limit`
  to) a count-matched request comes back SHORT of what it matched, so a view
  paged back past it keeps the unbounded shape.
- A view with a server span of zero has no count to match against, and that
  refresh is the client's only read of a transcript it holds nothing of, so it
  stays unbounded too.

Matching the count preserves the row COUNT, not the row IDENTITIES: when the
server gained rows while this client was away -- precisely the reconnect this
refresh recovers from -- the most-recent-N slice begins NEWER than the view's
oldest loaded row, and assigning it wholesale would delete that scrollback. The
page is therefore checked before it is fulfilled, and is safe on any one of three
counts: it reaches the START of history; it CONTAINS the view's oldest row (a
superset loses nothing, which is where the floor's over-request lands); or its own
oldest row is IN the view, so `olderHeadAbovePage` can cut a head to keep above
it. On none of the three, page and view are FULLY DISJOINT -- refetch unbounded,
rather than splice a disjoint page onto the view and publish a transcript with a
silent hole in it.

For the overlapping case `refreshSlot.fulfilled` keeps that head through the same
three shared helpers `switchSlot`/`warmSlotCache` use (`olderHeadAbovePage`,
`serverRowCount`, `pagingCursorAfterKeptHead`), which is what that cut exists for
-- a third reducer re-deriving it is how the first two diverged. The kept head
also shifts the older cursor, so "load earlier" is not a dead click, and
`windowComplete` for both reasoning helpers now describes the loaded window
rather than the fetch (it defaulted to `true`, a claim a bounded page cannot
make).

Bounding this fetch also moves it onto the OTHER branch of the slot-detail
handler, and the two branches did not agree about which store decides a row's
CONTENT. The unbounded branch returns `older + list(slot.messages)`, so the
window decides; the bounded branch reads chained disk history, so disk does.
That is invisible while the stores agree, and they stop agreeing exactly when a
row is rewritten IN PLACE and not yet flushed -- which is what a variant switch
is: `chat_regenerate` sets `_pending_rewrite` and broadcasts
`chat_variant_switch`, whose client-side handler dispatches this very refresh. So
the bound alone would have made selecting a variant paint the PREVIOUS one back
over it. `_append_unflushed_tail` does not cover it: it appends rows the disk
read is MISSING, and a rewritten row is not missing -- it is present and stale,
at the same `meta.mid`.

`_overlay_unflushed_edits` closes that: it substitutes each disk row for its live
window twin, matched on `meta.mid`, so the bounded branch agrees with the
unbounded one about content. A substitution and not an append, so `total`,
`has_more` and the slice boundary do not move; rows from older sessions in a
chained read match no id and pass through untouched. It runs unconditionally
rather than behind `_pending_rewrite`/`_dirty_flag`, so a third flag cannot
reopen the gap, and it reuses the `_snapshot_slot_window` pair the tail append
already captured on the loop rather than adding a second tearing surface.

The `warmSlotCache` half of the issue was already bounded by #3240 and is
untouched here.

Frontend tests assert the `limit` argument reaching `api.chatSlotDetail`, not
just the resulting state -- the argument is the fix, and a state-only assertion
would still pass with the bound removed. Backend tests pin the two branches
agreeing on content, and 4 of the 5 fail on this branch with the handler change
reverted.

Fixes #4690
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

/ai-review override gpt b400821: The finding's only remedy is to revert the bounded refresh hunk, which is the entirety of issue #4690, so it is a scope ruling rather than a code defect; the disk-vs-window disagreement pre-exists on main, is disclosed in the PR body §3, and is tracked as its own work in #7526.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Human judgment recorded

@iamwhatever marked the gpt AI finding as false positive, not applicable, or explicitly accepted for b4008212d18a8ec66d63d153683b8859a311d69a.

The finding's only remedy is to revert the bounded refresh hunk, which is the entirety of issue #4690, so it is a scope ruling rather than a code defect; the disk-vs-window disagreement pre-exists on main, is disclosed in the PR body §3, and is tracked as its own work in #7526.

This decision applies only to this commit. A new push requires a new judgment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(chat): bound the recurring refreshSlot / warmSlotCache history fetch

2 participants