fix(runtime-host): page oversized transcript Turns - #4433
Conversation
Astro-Han
left a comment
There was a problem hiding this comment.
Thanks — reviewed 073c4455. Approving; four P3s below, none blocking, all fine as follow-ups.
The problem is demonstrated without relying on the new tests. readRangeEdges throws once a single Turn passes SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES (256) or SESSION_TRANSCRIPT_RANGE_MAX_BYTES (16 MiB), and createSessionTranscriptBootstrap maps any non-TranscriptOverlayCapacityError into persistence_failed / "Session transcript is unavailable" — which is the log in #4428 verbatim. Those thresholds are Runtime Host's own, so a user cannot avoid them, and a 257-message tool loop is an ordinary size. The throw arrived with #4244 two days before the report, so this is a reachable regression rather than a hypothetical.
I checked the three claims in the summary rather than taking them:
- Pages stay bounded. The bound never came from
readRangeEdges— bootstrap reads throughreadDurablePage({ maxMessages: SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, … })and continuations throughcontinuationMessageLimit(position)withrequirePageByteLimitcappingmaxBytesat 512 KB.readRangeEdgesonly trims a selection back to whole-Turn edges, so returninginput.selecteduntouched leaves the reader's own limits in force. - Cursor continuation survives.
pageFromSelectionsigns the cursor fromselected.next, andcursorRangeBoundarySequenceis already null-valued elsewhere, soresolvePositionkeeps walking. A 286-message single Turn pages as 256 + 30 with no repeats and no gap. nullis inside the consumers' contract. Both production readers already handle it:session-subscription.ts:247treats a null boundary asreachedBoundary = true, and all three sites indesktop-transcript-replica.tswere already written aspage.protectedTurnSequence ?? …. And the degraded path returns at the 257th record, so it does not become a full scan.
"Fails without it" checks out — running the new test file against unmodified main gives 15 pass / 3 fail, and all three failures are the new cases hitting the target RangeError.
P3 — the second production change looks unreachable, and it leaves a dead throw carrying the same message. group[0] is a subset of input.selected, which is already bounded at ≤ 256 messages and ≤ 512 KB by requirePageByteLimit, so the groupStart === 0 branch cannot exceed 256 / 16 MiB; a target Turn over the limit has already returned at the earlier site. The three new cases all fail on main at the first site, and the second has no coverage. That doubles the production surface, and :423 still throws RangeError('Session transcript Turn range exceeds its capacity limit') — the identical string — so anyone grepping it will still read "oversized Turns throw", which is now wrong. Collapsing the limit check to the one authority in the scan loop lets both the second branch and that throw go, and the message disappears from the tree.
P3 — the running-Turn case duplicates the terminal-Turn one. Both use the same 286-message fixture and the former's assertions are a subset of the latter's; readRangeEdges never reads rootTurn, and the fixture reader ignores it too, so both land in the same return. #4428 asked for active-Turn coverage if the code path differs — it doesn't. Either drop it, or give it real activeAssistantStreams / overlay so it proves that degradation still holds once the overlay takes budget.
P3 — two assertions in the byte-bound case are tautological. With maxBytes at 512 KB and 600 KB messages, bootstrap can only return one truncated fragment, so fragments.length <= 256 and rawBytes <= 512 * 1024 hold under any implementation. The case earns its place as the only byte-bound coverage; assert.equal(fragments.length, 1) plus a continuation that retrieves the full message would make it say something.
P3 — worth naming what the degradation gives up. With protectedTurnSequence null, residentTurnKey groups this session under one Turn and #evictToBudget can't evict a protected key, so Desktop's resident set grows with the whole Turn rather than being capped by the Host's 256 / 16 MiB. It's bounded by that Turn, recovered by discard() on session switch, and before this change the session could not be opened at all — so the net is still an improvement. But "preserving bounded pages" is only half true, and the other half belongs in the summary with a pointer to #2913.
Evidence boundary: I read the pager against main, both production consumers of the boundary fields, the protocol limits, and the bootstrap error mapping, and ran the test file three ways (PR head 18/18, main 16/16, main production + PR tests 15/3). I did not exercise the real SQLite reader — the fixture ignores maxStoredBytes, so the byte path is verified against the fixture rather than storage — and I did not open a 286-message session in Desktop to confirm the error is gone.
AI-assisted review: drafted with Maka; I verified the boundary consumers, the continuation cursor, and the three-way test run against the branch source myself.
A Turn larger than the Host's own range limits made its Session unopenable. `readRangeEdges` trims a selection back to whole-Turn boundaries, and when the target Turn alone exceeded `SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES` (256) or `SESSION_TRANSCRIPT_RANGE_MAX_BYTES` (16 MiB) it threw `RangeError`. `createSessionTranscriptBootstrap` maps anything that is not a `TranscriptOverlayCapacityError` to `persistence_failed`, so the reader saw "Session transcript is unavailable" and no amount of retrying helped. The limits are the Host's own, so nothing on the client side could avoid them, and a 257-message tool loop is an ordinary size. The throw was added in apache#4244 as the fallback for "not even one Turn fits". Degrade instead of refusing: when a single Turn cannot be bracketed, return the selection unchanged with null range and protected-Turn boundaries. Nothing else moves. The page stays bounded because the bound never came from this function — bootstrap reads through `readDurablePage` with the range message limit and continuations through `continuationMessageLimit`, with `requirePageByteLimit` capping page bytes at 512 KB. Continuation still works because `pageFromSelection` signs its cursor from the reader's `selected.next`, and a null boundary is already a value both consumers handle: `session-subscription.ts` reads it as "boundary reached", and `desktop-transcript-replica.ts` was already written with `?? fallback` at all three sites. Ordinary Turns and partial-edge Turns take the same branches as before — the conditions are untouched. An oversized Turn now pages: 286 messages arrive as 256 + 30, with no repeats and no gap. One consequence worth recording: with a null protected-Turn boundary, Desktop groups the session under a single resident Turn key and cannot evict it, so its resident set is bounded by that Turn rather than by the Host's 256 / 16 MiB. That is a smaller problem than being unable to open the Session at all, and it belongs to the bounded-transcript work in apache#2913. Fixes apache#4428 Generated-by: OpenAI Codex
Summary
Allow Runtime Host transcript bootstrap to degrade oversized target Turn range metadata to null boundaries, preserving bounded pages and cursor-based continuation. Ordinary Turn and partial-edge Turn protection remains unchanged.
Fixes #4428
Verification
npm run astryx:surface-inventory— passnpm run astryx:surface-inventory:test— 15/15 passnpm run lint— passnpm run format:check— passnpm run build:test— passnode --test packages/runtime-host/dist/__tests__/session-transcript-pager.test.js— 18/18 passnpm --workspace @maka/runtime-host run typecheck— passnpm run test:dist— all workspace tests passgit diff --check— passAI use
Select exactly one:
Tool(s) and scope: OpenAI Codex — diagnosis, implementation, regression tests, CI investigation, and PR text.
Checklist
Does this PR entail a change in behavior?