Repository navigation
refactor(native-chat): read the chat queue by row headers instead of loading every message - #26651
brennanb2025 wants to merge 7 commits into
Conversation
…loading every message Every queue decision (next card, pauses, consume recheck, settlement, retention, head placement, admission sum) loaded and parsed every stored row of every state and cached the whole array per chat. Read indexed, body-free headers instead and stop at the first decision; bodies are read only for the card being handed off, explicit card actions, receipts, /clear's carry and the existing full-list publication. No behaviour change.
Check whether the agent is working before reading the queue, skip the resumable-pause walk when nothing is paused, and probe settlement only for sends that took a card. One readable-row definition backs the partial indexes, header reads and body reads, so a corrupt row can never be picked and then fail to load.
Drop the all-state readable index and the consume-state index (they cost every write and served rare or unproven reads) and the extra readable_body column on body reads; JSON.parse already rejects the corrupt shapes. Receipts read exactly as before.
…nable The new queue indexes only speed up reads. Build them best-effort after the required schema, so a damaged row elsewhere or a full disk logs once instead of refusing every chat.
…ue-own-read # Conflicts: # src/main/native-chat/agent-session-journal/queued-message-settlement.ts
…ue-own-read # Conflicts: # src/main/native-chat/agent-session-journal/journal-queued-messages.ts # src/main/native-chat/agent-session-journal/queued-message-pause.ts
Review summaryProblem. The host's chat queue answered every question ("what goes next?", "is the queue paused?", "which old receipts can be cleaned up?") by loading every queued row it had ever saved for that chat, full message text included, and keeping that whole list in memory. The work and memory grew with the chat's whole queue history, not with what was actually waiting, and this whole-list read is what forces today's "too much text is waiting" limit. User-facing change. None intended. Queue order, pauses, holds, Resume, Review. Three review rounds plus a readiness pass, by reviewers who did not write the code. Round 1 also had an independent design challenger, which kept the design (read the existing rows by their small header fields; no second store) and found no reason to split the PR. Comparing old and new logic on 300,000 random queue/pause cases and 9,000 bookkeeping cases found zero differences. Fixed during review:
Deferred (nits or later PRs in this sequence):
Verified.
Not verified.
|
ELI5
When an agent is busy, messages sent to its chat wait in a queue. Orca keeps each waiting message as its own saved row, but every time it asked a simple question about the queue ("what goes next?", "is anything paused?", "can old receipts be cleaned up?") it loaded every row ever saved for that chat, including the full text of every message and every already-delivered one, and kept all of it in memory. This change makes those questions read only the few small fields they need, from indexes, and stop as soon as they have an answer.
Nothing looks or behaves differently. This is the first step of making the queue a separate, paged read, so that later steps can stop refusing messages because "too much text is waiting".
What Changed
Problem. The host's queue repository had one read,
listQueuedMessages, that selected every row of every state (waiting, returned, handed off, and withdrawn receipts) and parsed every message body. The journal cached that whole array per chat. Everything went through it: picking the next message to send (asked on every streamed frame of a running turn), working out whether a Stop,/clearor reopen pauses the queue, the in-transaction recheck before a message is handed to the agent, the open-time repair and cleanup of old receipts, placing kept messages at the head of the queue, and the admission check that sums the size of all waiting text. So memory and per-step work grew with the whole history of the chat's queue, not with what was actually waiting, and the bodies stayed resident on every open chat.User-facing change. None intended. Same queue order, same pauses and holds, same "Too much text is waiting" limit and wording (it is removed in a later step), same cards on desktop and phone, same
/clearbehavior.Mechanism.
queued-message-headers.tsadds body-free reads over the existing table: an ordered header iterator (waiting / unsettled / all), point lookups by id and by hand-off link, existence checks, a keyset walk over expired handed-off rows for retention, and the admission byte sum computed in SQLite (length(CAST(body_json AS BLOB)), which equals the UTF-8 length of theJSON.stringify(body)the insert stores).IF NOT EXISTS, like the table itself (no schema-version bump, so an older build still opens the database):(session_id, position), a partial(session_id, position)over readable waiting/returned rows, and(session_id, state, settled_at, message_id). They only make reads faster (every query carries its full condition), so they are built best-effort after the required schema: if a build fails, for example because one chat's row is already damaged on disk or the disk is full, Orca logs it once and the journal still opens; the next open tries again. Without this, one damaged row would have made every chat on that host refuse to open.queued-message-pause.tsstay the single source of truth; they now accept an iterable and stop at their first decision./clear's check for queued commands to withdraw, and today's full-list publication to phones and paired computers. That publication now selects only waiting/returned rows in SQL and still recomputes only when the queue changes; the per-chat cache of every row is gone.queued-message-readability.ts) is used by every header query and is part of the partial index's condition, so SQLite checks it once when a row is written instead of re-parsing every body on every read. A row the drain can pick is therefore always a row it can load; a corrupt row can't stall the queue.Why
The root cause of the queue limits is that the host treats its queue as one value that has to be loaded, and later published, whole. The rows are already separate, so the fix is to read them as separate rows instead of adding a second store. This PR does that for every host-side decision. The next PRs give phones and paired computers a separate, paged queue read (so the queue stops riding every chat frame), switch desktop and phone to it, and then delete the aggregate size refusal.
Alternatives considered: keeping the whole-list read and just paging the published list afterwards (still loads every body for every decision), or a second queue table (duplicates the ownership and hand-off rules that already work).
Differences from the common pattern (the common pattern stores queued messages as separate rows and serves them as their own read, invalidated on change):
Linked Issue
N/A (maintainer). Follows the merged count fix #26553.
Visual Proof
No visual change is intended; these are a smoke test of an earlier head of this PR (7fe88d8, with main's queued
/compactmerged in; before the last main sync that brought in/clearstaying in the same conversation) in a hidden test build on Windows, using a stand-in agent and the queued-messages capability turned on for the test build only.Three messages queued behind a running turn, in order:

After the turn ends they are sent one at a time, in order, each as its own turn:

Stop with two cards waiting: the cards stay, "Queue paused because you interrupted" with Resume; nothing was sent for about 85 seconds:

After Resume both were sent in order:

A typed card ahead of a


/compactcard; the typed message ran first, then the compaction:Testing
Targeted vitest after the final main sync (which brought in
/clearstaying in the same conversation, Keep native chat /clear in the same conversation #26579): 59 queue/journal/wire/clear test files, 580 tests pass, including main's/compactand/clearqueue suites. The/clearpause now holds exactly the message ids the clear recorded; the header walk keeps that rule and stops once every recorded card is found. New tests:queued-message-headers.test.ts,journal-queued-message-reads.test.ts(header reads never select bodies; next-card pick stops early; retention and owed settlement with thousands of withdrawn rows touch only the rows they act on; SQL byte sum equals the JS measurement for multibyte/emoji/escaped text; FIFO and pauses identical on a mixed queue; a rolled-back consume leaves no stale read),queued-message-read-plans.test.ts(each query uses its intended index),queued-message-readability.test.tsandstructured-agent-session-queued-read-regressions.test.ts(a corrupt NUL/BLOB card at the head is never picked and the next card is sent; no queue read while the agent works),journal-queued-message-index-failure.test.ts(a failed index build leaves the journal openable and reads working),queued-command-owed-settlement.test.ts(a refused queued/compactrecovered after a restart is spent, not returned).Reviewers also compared old and new logic on 300,000 random queue/pause cases and 9,000 bookkeeping cases with zero differences.
tc:nodetypecheck clean;orca-ci-checks(lint, code quality, localization, typecheck) clean.Platforms: logic is SQLite and shared TypeScript only; no platform, path or process code changed. SSH/paired hosts run the same host code on the same SQLite version.
I manually tested these changes locally (UI smoke was agent-driven on a hidden Windows test build; see Visual Proof)
Automated tests added/updated, or explained why not below
Agent skill upstream boundary
Notes
Security: no new inputs. Cross-platform: SQLite only. Remote SSH: the execution host owns the queue as before. Mobile: unchanged frames. Backwards compatibility: indexes are additive and created with
IF NOT EXISTS; an older build ignores them. Performance: removes whole-table body hydration from the drain and bookkeeping paths.Author: @BrennanKB5