Skip to content

fix(native-chat): carry the retained submission window on a replacing history page - #20586

Closed
brennanb2025 wants to merge 3 commits into
mainfrom
brennanb2025/fix-native-chat-stale-submission-wedge
Closed

brennanb2025 wants to merge 3 commits into
mainfrom
brennanb2025/fix-native-chat-stale-submission-wedge

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​556 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​556
Prod 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​206 $\color{#cf222e}{\Huge{\mathbf{−}}}$​14 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​192

The bug

A structured native-chat pane goes silently unsendable: typing a message queues it forever, and a false "Working for N" spinner runs indefinitely. Four live panes were in this state, all Claude, none of the 21 live Codex panes. One had two user messages stranded behind it for five hours, against a turn that had completed.

Mechanism

Since #19863 the Claude lane returns from dispatch on the transport write and writes no dispatch row. The submission rests pending, and a single at-most-once streaming frame carries the accepted record. That change was right -- it removed a false "unconfirmed" banner firing on 25% of real sends -- but it left one frame as the sole carrier, and Codex was deliberately left on the old inline path. Hence the provider asymmetry.

A pane not subscribed when that frame publishes never sees the row, and that needs no fault anywhere: publish() over an empty subscriber map is zero deliveries with no error and no queue, close() deletes the subscriber record outright, and there is no per-client delivery ledger. On re-attach the client supplies a cursor and the host reads strictly after it.

Five sites advance a cursor past rows they did not deliver -- the reducer's tail-page replace, the subscribers' reset branch, replay(), a cold open(), and the read transport's resume. All five ship a hydration or tail page.

Repair was then impossible, because a page carried only the submissions whose user item survived the page's item window, and mergeSubmissions overwrites only on key collision. Measured on a real journal: submission at seq 90, accepted at seq 91, turn completed, and the submission's item at index 34 of 331 live items -- far outside the 200-item tail window. Every later page reported no submissions at all, so the stale pending survived. The outbox head parked at dispatching, which the head-of-line pump never dequeues, and hasUnansweredStructuredAgentSessionDispatch kept the spinner alive off the same row.

The page filter is not new; it predates #19863 and was harmless while a reliable second carrier existed.

The fix

A replacing page carries the renderer's whole retained submission window, not just the submissions its items reference. after keeps the item window: it is the live streaming batch, which already carries every submission it touches, and widening it would rebuild the array on every frame and churn every consumer for the duration of a turn -- including mobile, which does AsyncStorage work whenever settled sends change.

The tail-page early return now reconciles submissions. Widening the page alone fixed nothing for a live pane: that branch discards a page whose cursor is not ahead of the renderer's, and a wedged pane is head-parked by construction, so every refresh was a no-op. Window focus is the gesture users actually perform and it routes straight into that return. Merging touches no items, so the branch's reason for existing -- not discarding paged-in older items mid-scroll -- is unaffected. It merges against state.items rather than the page's, because the page window can be narrower and evicting a retained submission re-opens the same hole from the other side. It is gated on cursor equality only: dispatch states are not monotone, a proven retry returns a submission to pending, and merging a strictly-behind page could clobber a fresh accepted and un-settle a message the outbox had already released.

Equality there is structural, not referential. A widened page carries every retained key, so the merge overwrites each with a fresh wire object and a reference check would never match -- minting a new state object on every focus.

A dispatch reason is now bounded, at two different limits for two different reasons. It was the only unbounded field written by Orca's own code, and a replacing page carries the whole retained window, so 256 records at the journal's 16 KiB inline head reach the 4 MiB outbound cap exactly: the read throws rather than merely truncating. The write site bounds against journal growth; the page bounds at a limit derived from the page budget and the window size, which is what protects journals already holding unbounded reasons on disk. The window itself stays count-bounded, so the host and the renderer's own trim remain one definition -- bounding the field never drops a record, and a dropped record is exactly a settlement that never reaches a re-attaching pane.

MAX_RETAINED_SUBMISSIONS moves to its own module so both sides share one definition. clientMessageId is deliberately untouched: it is the join key the renderer matches outbox entries against, and truncating it would silently rebuild this bug.

Testing

Every test was observed failing before its own fix, with single-lever arms confirming each fails for its own reason:

  • a settled submission whose item aged out of the page (expected [] to match object [ { clientMessageId: 'msg-1', dispatchState: 'accepted' } ])
  • a head-parked pane's missed settlement (dispatchState: "pending" where "accepted" was expected)
  • the ownership fence on a page that omits one (expected null to be 7)
  • the after gate holding a forward page to its item window
  • the page still serving real items when every retained submission carries a long reason (RemoteRuntimeClientError: Remote runtime JSON payload exceeds 4194304 bytes)
  • the field bounded rather than records dropped (expected Array(197) to have a length of 256)

The two reason bounds are pinned independently: the write-site lever observes the reduced snapshot and never a page, and the page-side lever starts from a row inserted directly into the database, bypassing the write site. Without that asymmetry the page-side bound -- the half protecting journals already in the field -- would have passed on the write-site bound alone and gone unpinned.

  • pnpm tc clean
  • src/main/native-chat/ -- 141 files, 1130 passed
  • src/shared/structured-agent-session -- 15 files, 146 passed
  • src/main/native-chat/agent-session-journal/ -- 19 files, 210 passed
  • renderer native-chat and runtime -- 326 files, 3026 passed
  • check:code-quality:changed -- 0 new findings, all five gates
  • max-lines linted against the merge result, not just this head: clean

Guards

Three pieces of this change could be removed with every test still green. Each is now pinned.

submissionsEqual was a hand-maintained field list. A tenth field on AgentJournalSubmission would make a page whose only correction is that field compare equal, so the reducer keeps state and the settlement never lands -- this branch's own bug, re-created by an edit to an unrelated file. The list is exhaustive at compile time; omitting a field fails the build and names the field. A runtime test flips each field independently, which the type guard cannot do. Verified firing with an optional tenth field across all three tsconfig projects.

itemBudgetBytes was unpinned -- removing the reservation failed no test in the entire wire directory. It protects the page cap, not the channel cap, which is exactly why the existing transport gate cannot see its absence: measured at 1,993,060 bytes with the reservation and 2,301,089 without, against a 2,097,152 page cap, with both arms under the 4 MiB channel. The overflow is silent. Pinned at both call sites against AGENT_SESSION_HISTORY_MAX_PAGE_BYTES, with an item-count assertion ahead of the byte check so it cannot pass on a fixture that never filled the budget.

The page bound must keep the first 23 bytes of a reason, because dispatchRejectionWasTransportWriteFailure prefix-matches it and a clipped transport failure that stops classifying renders raw internal text to the user. Pinned at both bounds this branch introduces. The concrete hazard is a tail-preserving bound. Note the codebase's other bounding idiom, boundJournalKeyComponent, does not break classification -- it keeps a 40-unit head and the prefix is 23 bytes -- but it does drop the truncation marker, so a clipped reason would read as the provider's complete explanation. The comment records the narrower true constraint rather than the broader plausible one.

Not addressed

providerItemId and payloadFingerprint are unbounded on the wire the same way reason was. They are not join keys, so bounding them is possible, but first-party clients never produce large ones and admission-time refusal is the honest remedy -- a separate change.

Some wedged entries are missed frames whose item is still inside the window; those already heal on the next transport run. The emit path at structured-agent-session-subscribers.ts still swallows a throw and silently drops a subscriber -- after this change such a loss is recoverable on the next page, but it remains unreported. A submission stranded pending within a live fence now reads working on the pane as well as the session list, which already showed it; giving that case a settlement path is a follow-up.

clientMessageId is deliberately excluded, not overlooked. It is unbounded on the wire the same way providerItemId and payloadFingerprint are, but it must not be bounded: it is the join key reconcileStructuredAgentSessionOutbox matches outbox entries against (structured-agent-session-outbox.ts:99-101), so truncating it on the page rebuilds exactly the wedge this PR fixes, and truncating it at the write site hands the client back an id it cannot match. Refusal at admission is the only honest remedy. Do not "finish the set" by bounding it alongside the other two.

Budget-test margin, for whoever trips it. leaves room for the retained window when items alone would fill the page passes with 103,947 bytes of headroom against the page cap. A future change that grows the page envelope past that will fail it, and the failure will read as a budget regression when the real cause is envelope growth.

… history page

A structured send whose settlement arrived while no pane was subscribed
could never be reconciled, leaving the message silently unsendable and a
false "Working for N" spinner running indefinitely.

Since #19863 the Claude lane returns from dispatch on the transport write
and writes no dispatch row: the submission rests `pending` and a single
at-most-once streaming frame carries the `accepted` record. A pane that is
not subscribed when that frame publishes re-attaches, advances its cursor
to the journal head, and never sees the row. Five sites advance a cursor
past rows they did not deliver -- the reducer's tail-page replace, the
subscribers' reset branch, `replay()`, a cold `open()`, and the read
transport's resume -- and all five ship a hydration or tail page.

Repair was impossible because a page carried only the submissions whose
user item survived the page's item window, and the renderer overwrites a
submission only on key collision. Measured on a real journal: submission at
seq 90, `accepted` at seq 91, turn completed, and the item at index 34 of
331 -- far outside the 200-item tail window. Every later page reported no
submissions at all, so the stale `pending` survived. The outbox head parked
at `dispatching` and the head-of-line pump never dequeued; two user
messages sat behind it for five hours.

A replacing page now carries the renderer's whole retained submission
window. `after` keeps the item window: it is the live streaming batch,
which already carries every submission it touches, and widening it would
rebuild the array on every frame. Those extra records are priced into the
page byte budget, which charges a submission against its own item and so
cannot see one riding with an off-window item.

Separately, a tail refresh no longer nulls the renderer's ownership fence.
`agentSession.history` never stamps one, so every refresh cleared it, and a
null fence silently holds both the outbox pump and the unconfirmed probe
with nothing shown in the UI. The same-cursor branch already preserved it.
@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: e084413a-5505-49ca-aad0-0cda1aa48031

📥 Commits

Reviewing files that changed from the base of the PR and between 774a81e and 9f4be37.

📒 Files selected for processing (5)
  • src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts
  • src/main/native-chat/agent-session-wire/agent-session-history-page-budget.test.ts
  • src/main/native-chat/agent-session-wire/dispatch-reason-bound-head-preservation.test.ts
  • src/shared/agent-session-submission-equality.test.ts
  • src/shared/agent-session-submission-equality.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

History pages retain the newest 256 submissions and reserve byte budget for them. Forward pages include item-referenced submissions. Replacing pages include retained submissions and referenced submissions. Dispatch reasons are bounded when journal rows are written and when pages serialize persisted rows. The reducer updates equal-cursor tail pages when submission data changes and preserves an existing fence when a page omits one.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 9f4be

No actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 24 functions across 12 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description gives detailed technical context, scope, rationale, testing, and limitations. However, it omits several required template sections, including ELI5, What Changed, Why, Linked Issue, Vis… Add the missing template sections. Include the required linked issue, provide visual proof or state N/A with a reason, complete the testing and checklist items, and address the remaining repository-specific sections.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: carrying the retained submission window on replacing native-chat history pages.
Full details: Description check

Explanation

The description gives detailed technical context, scope, rationale, testing, and limitations. However, it omits several required template sections, including ELI5, What Changed, Why, Linked Issue, Visual Proof or N/A, AI Disclosure, Review, Agent skill upstream boundary, Notes, and the checklist.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 97a16973-8ba7-484a-bb0a-ad5566393bb9

📥 Commits

Reviewing files that changed from the base of the PR and between 4634d2c and e896628.

📒 Files selected for processing (5)
  • src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts
  • src/main/native-chat/agent-session-wire/agent-session-history-page.ts
  • src/shared/structured-agent-session-reducer.test.ts
  • src/shared/structured-agent-session-reducer.ts
  • src/shared/structured-agent-session-submission-retention.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

…issed

Follow-up to the page widening in this branch: the widened page never
reached the pane it was written for.

The `tail-page` early return discards a page whose cursor is not ahead of
the renderer's. A wedged pane is head-parked by construction -- its cursor
tracks the journal head through streaming batches, and a tail refresh
carries that same head -- so every refresh was a no-op and the widened
submissions were dropped on arrival. Window focus is the gesture users
actually perform, and it routes straight into that return.

The branch now reconciles submissions there. Merging touches no items, so
its reason for existing -- not discarding paged-in older items mid-scroll
-- is unaffected. It merges against `state.items`, not the page's, because
the page window can be narrower and evicting a retained submission would
re-open the same hole from the other side. It is gated on cursor equality
only: dispatch states are not monotone, a proven retry returns a submission
to `pending`, and merging a strictly-behind page could clobber a fresh
`accepted` and un-settle a message the outbox had already released.

Equality is structural, not referential. A widened page carries every
retained key, so the merge overwrites each one with a fresh wire object and
a reference check would never match -- minting a new state object on every
focus and re-rendering the transcript.

A dispatch `reason` is now bounded. It was the only unbounded field written
by Orca's own code, and a replacing page carries the whole retained window,
so 256 records at the journal's 16 KiB inline head reach the 4 MiB outbound
channel cap exactly -- the read fails to serialize rather than merely
truncating. It is bounded twice, at different limits, for different
reasons: at the write site against journal growth, and on the page at a
limit derived from the page budget and the window size, which is what
protects journals already holding unbounded reasons on disk.

Six tests, each observed failing for its own reason, including one pinning
that the page bounds the field rather than dropping records to fit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • A replacing page now carries the retained submission window — pageSubmissions in agent-session-history-page.ts sends the newest MAX_RETAINED_SUBMISSIONS (256) snapshot submissions plus the ones whose item is on the page for tail/before, while after keeps the item-scoped set. This restores the carrier a pane that attached after the sole accepted streaming frame published.
  • Shared retention definition — MAX_RETAINED_SUBMISSIONS and retainedSubmissionWindow move into structured-agent-session-submission-retention.ts, so the host page width and the renderer's mergeSubmissions retention can no longer drift apart.
  • Byte budget covers the widened payload — itemBudgetBytes reserves the retained submission bytes before boundHistoryItemsByItems bounds the item window, keeping the page inside HISTORY_PAGE_CONTENT_BUDGET_BYTES.
  • Tail refresh keeps the ownership fence — the tail-page replace branch now uses action.page.fence ?? state.fence, matching the same-cursor branch, so a fence-less refresh no longer holds the outbox pump and unconfirmed probe.
  • Two regression tests — one asserts the page carries a settled submission whose item aged out (with an explicit precondition that the item is absent from page.items); the other asserts the fence survives a tail refresh with no fence.

Verification this run: pnpm test on agent-session-history-page.test.ts and structured-agent-session-reducer.test.ts passes (40 tests); snapshot submissions are submittedAt-ascending (journal-reducer.ts:339) so the page window aligns with mergeSubmissions retention; submission records are metadata-only, so the widened window stays small and the byte accounting is conservative.

ℹ️ retainedSubmissionWindow selects by submittedAt alone

The page slices the newest 256 of the host's submittedAt-sorted list while mergeSubmissions slices the newest 256 of the renderer's accumulated union. Same-millisecond ties could in principle order differently across the two and place one tied submission on opposite sides of the 256 boundary, so a page could omit a fresh copy the reducer still holds. Reaching it needs more than 256 submissions sharing a millisecond, which user sends cannot produce, so this is an observation rather than a request — noting it because it is the exact invariant the fix leans on.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog

pullfrog Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Three pieces of this branch could be removed with every test still green.

`submissionsEqual` was a hand-maintained field list. A tenth field on
`AgentJournalSubmission` would make a page whose only correction is that
field compare equal, so the reducer would keep `state` and the settlement
would never land -- this branch's own bug, re-created by an edit to an
unrelated file. The list is now exhaustive at compile time: omitting a
field fails the build here and names the field it missed. A runtime test
flips each field independently, which the type guard cannot do.

`itemBudgetBytes` was unpinned -- removing the reservation failed no test
in the whole wire directory. It protects the page cap, not the channel
cap, which is why the existing transport gate cannot see its absence: a
page overruns `AGENT_SESSION_HISTORY_MAX_PAGE_BYTES` by ~204 KB and the
4 MiB channel never throws. Measured at both call sites and asserted
against the page cap, with an item-count assertion so the byte check
cannot pass on a fixture that never filled the budget.

The page bound must keep the first 23 bytes of a reason, because
`dispatchRejectionWasTransportWriteFailure` prefix-matches it and a clipped
transport failure that stops classifying renders raw internal text to the
user. Pinned at both bounds this branch introduces. The concrete hazard is
a tail-preserving bound; the codebase's other bounding idiom keeps a
40-unit head, so it does not break classification -- the comment says so
rather than overstating the threat.

Also corrects the page-limit comment: the divisor reads as an eighth of the
budget but bounds raw bytes while the page is priced in JSON, so escaping
takes a full window to a measured 79%. It still fits because the reserve
prices the serialized record.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Reconciles a missed settlement on the tail-page early return — the same-cursor branch now computes mergeSubmissions(state.submissions, page.submissions, state.items) and returns new state when submissionListsEqual reports a change, so a head-parked pane finally picks up an accepted it was never subscribed for. Kept off state.items because a narrower page window must not evict a retained submission, and gated on cursor equality because dispatch states are not monotone.
  • Structural submission equality extracted to a shared module — submissionsEqual/submissionListsEqual live in agent-session-submission-equality.ts, derived from a COMPARED_FIELDS list whose everyComparedField guard turns a newly-added AgentJournalSubmission field into a compile error rather than a silent missed refresh. This is what keeps the widened page from minting new transcript state on every focus.
  • A dispatch reason is bounded at both ends — the write site clips at 16 KiB via boundedDispatchReason, and boundedPageSubmissions clips again at DISPATCH_REASON_PAGE_LIMITS (budget/8/256) so journals already holding unbounded reasons on disk cannot push a replacing page past the outbound cap. Clipping is head-preserving, and the retained record is never dropped; submissionBytesByItemId prices the clipped form so the budget reservation stays bounded.
  • Guard and regression tests — head-preservation of the clipped transport prefix at both bounds (independent levers), retention boundary, after staying item-scoped, the page budget reservation on tail and hydration pages, and the equality field guard.

The two new production commits are well-tested and internally consistent. I verified the byte accounting covers every carried record (retained → reserved, on-page → charged per item), that the head-preserving clip keeps dispatchRejectionWasTransportWriteFailure classifying a clipped transport failure, and that equal-cursor freshness makes the reconcile safe. All focused suites pass locally (equality 5, head-preservation 3, retention 2, budget 2, history-page 26, reducer 19).

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Superseded by #20581, which landed the same fix with a better mechanism.

Both PRs diagnose the same defect: a bounded tail read moved the client cursor to the journal head while carrying only a page of items, the send's user item fell off that page, its submission was not carried across the merge, the retained pending survived, and the cursor advanced past the dispatch-acceptance row that would have settled it.

This PR widened the page so the record could still arrive, then reconciled it in the tail-page branch. #20581 deleted that branch instead — the replica has one position and one body, replace and append keep them consistent, and tail-page was a third operation that took position and body from different places. Removing the operation removes the drift; a live subscription is the only thing that moves the cursor, and subscribe({ cursor }) already replays exactly the missed rows. That is the right cut, and it removes the bug class rather than guarding it.

With #20581 merged, the substance here no longer applies:

  • the equal-cursor reconciliation modifies a branch that no longer exists
  • widening a replacing page's submission set is no longer load-bearing: a warm owner never takes a bounded page, and a cold one starts empty, so there is no retained pending to preserve
  • the fence preserve is moot, since history-page now only fires on cold hydration
  • the exhaustiveness guard and the page-budget pin exist to protect those two changes

The one independent piece is split out as #20654: reason was the only unbounded field written by Orca's own code, and journalDispatchRowBuilder stored a provider's raw error.message verbatim regardless of any of this.

Findings worth keeping from the review here, recorded so they are not lost with the branch:

  • clientMessageId must never be bounded. It is the join key reconcileStructuredAgentSessionOutbox matches outbox entries against, so truncating it on the page or at the write site silently breaks send reconciliation. Refusal at admission is the only safe remedy. providerItemId and payloadFingerprint can be bounded safely; do not "finish the set" by including clientMessageId.
  • backgroundTaskStatesEqual has no exhaustiveness guard, the same shape as the one added here for submissionsEqual.
  • markJournalPendingSubmissionsUnknown re-writes an already-clipped reason, producing a second truncation marker whose digest describes already-truncated text.
  • A submission stranded pending within a live fence has no settlement path; reconcileJournalSubmissionsAgainstHistory only runs at attach, which is a new fence.

Closing in favour of #20581 and #20654.

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.

1 participant