Skip to content

Commit afbcabd

Browse files
authored
fix(runtime-host): page oversized transcript Turns (#4433)
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 #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 #2913. Fixes #4428 Generated-by: OpenAI Codex
1 parent 6668af3 commit afbcabd

2 files changed

Lines changed: 155 additions & 19 deletions

File tree

packages/runtime-host/src/__tests__/session-transcript-pager.test.ts

Lines changed: 145 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -360,32 +360,160 @@ test('opens the complete latest Turn when bootstrap starts inside its assistant'
360360
assert.equal(decoded.nextCursor, null);
361361
});
362362

363-
test('rejects a latest Turn that exceeds the Host range message bound', async () => {
364-
const durable = Array.from({ length: 257 }, (_, index) => ({
363+
test('pages through a terminal Turn that exceeds the Host range message bound', async () => {
364+
const durable: StoredMessage[] = Array.from({ length: 286 }, (_, index) => ({
365365
...assistantMessage(index),
366366
turnId: 'turn-1',
367367
}));
368-
369-
await assert.rejects(
370-
createSessionTranscriptBootstrap({
371-
reader: transcriptReader(durable),
368+
const reader = transcriptReader(durable);
369+
const { bootstrap, state } = await createSessionTranscriptBootstrap({
370+
reader,
371+
sessionId: 'session-1',
372+
subscriptionId: 'subscription-1',
373+
throughSequence: durable.length - 1,
374+
rootTurn: {
372375
sessionId: 'session-1',
376+
turnId: 'turn-1',
377+
runId: 'run-1',
378+
status: 'completed',
379+
terminalEventId: 'terminal-1',
380+
},
381+
activeAssistantStreams: [],
382+
maxBytes: 512 * 1024,
383+
projection: 'owner',
384+
});
385+
386+
assert.equal(bootstrap.durable.fragments.length, 256);
387+
assert.equal(bootstrap.durable.rangeBoundarySequence, null);
388+
assert.equal(bootstrap.durable.protectedTurnSequence, null);
389+
assert.ok(bootstrap.durable.nextCursor);
390+
391+
const subscription = new ClientSessionSubscription(
392+
{
393+
hostEpoch: 'host-1',
373394
subscriptionId: 'subscription-1',
374-
throughSequence: durable.length - 1,
375-
rootTurn: {
376-
sessionId: 'session-1',
377-
turnId: 'turn-1',
378-
runId: 'run-1',
379-
status: 'running',
380-
},
395+
nextSequence: 1,
381396
activeAssistantStreams: [],
382-
maxBytes: 16 * 1024,
383-
projection: 'owner',
384-
}),
385-
/Turn range exceeds its capacity limit/,
397+
transcript: bootstrap,
398+
snapshot: {
399+
schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION,
400+
session: {
401+
sessionId: 'session-1',
402+
metadataRevision: 1,
403+
status: 'active',
404+
createdAt: 1,
405+
isArchived: false,
406+
},
407+
projectionRevision: 1,
408+
rootTurn: {
409+
sessionId: 'session-1',
410+
turnId: 'turn-1',
411+
runId: 'run-1',
412+
status: 'completed',
413+
terminalEventId: 'terminal-1',
414+
},
415+
goal: null,
416+
queue: { hostEpoch: 'host-1', queueRevision: 1, steering: [], followup: [] },
417+
interactions: { pending: [] },
418+
},
419+
},
420+
async () => undefined,
421+
(request) => readSessionTranscriptPage({ reader, state, request }),
422+
);
423+
const decodeStoredMessage = (value: unknown): StoredMessage =>
424+
decodePersistedStoredMessage(markPersisted<StoredMessage>(value));
425+
const sequences: number[] = [];
426+
let pageCount = 0;
427+
let page = bootstrap.durable;
428+
429+
for (;;) {
430+
pageCount += 1;
431+
const decoded = await subscription.decodeTranscriptPage(page, decodeStoredMessage);
432+
sequences.push(...decoded.messages.map(({ identity }) => identity));
433+
if (decoded.nextCursor === null) {
434+
assert.equal(page.nextCursor, null);
435+
break;
436+
}
437+
page = await readSessionTranscriptPage({
438+
reader,
439+
state,
440+
request: {
441+
subscriptionId: 'subscription-1',
442+
source: 'durable',
443+
direction: 'older',
444+
throughSequence: durable.length - 1,
445+
cursor: decoded.nextCursor,
446+
anchorSequence: null,
447+
maxBytes: 512 * 1024,
448+
},
449+
});
450+
}
451+
452+
assert.equal(pageCount, 2);
453+
assert.equal(new Set(sequences).size, durable.length);
454+
assert.deepEqual(
455+
[...sequences].sort((left, right) => left - right),
456+
Array.from({ length: durable.length }, (_, index) => index),
386457
);
387458
});
388459

460+
test('degrades the range boundary for an oversized running Turn', async () => {
461+
const durable: StoredMessage[] = Array.from({ length: 286 }, (_, index) => ({
462+
...assistantMessage(index),
463+
turnId: 'turn-1',
464+
}));
465+
const { bootstrap } = await createSessionTranscriptBootstrap({
466+
reader: transcriptReader(durable),
467+
sessionId: 'session-1',
468+
subscriptionId: 'subscription-1',
469+
throughSequence: durable.length - 1,
470+
rootTurn: {
471+
sessionId: 'session-1',
472+
turnId: 'turn-1',
473+
runId: 'run-1',
474+
status: 'running',
475+
},
476+
activeAssistantStreams: [],
477+
maxBytes: 512 * 1024,
478+
projection: 'owner',
479+
});
480+
481+
assert.equal(bootstrap.durable.fragments.length, 256);
482+
assert.equal(bootstrap.durable.rangeBoundarySequence, null);
483+
assert.equal(bootstrap.durable.protectedTurnSequence, null);
484+
assert.ok(bootstrap.durable.nextCursor);
485+
});
486+
487+
test('degrades the range boundary for a Turn that exceeds the byte bound', async () => {
488+
const durable: StoredMessage[] = Array.from({ length: 32 }, (_, index) => ({
489+
...assistantMessage(index),
490+
turnId: 'turn-1',
491+
text: 'x'.repeat(600 * 1024),
492+
}));
493+
const { bootstrap } = await createSessionTranscriptBootstrap({
494+
reader: transcriptReader(durable),
495+
sessionId: 'session-1',
496+
subscriptionId: 'subscription-1',
497+
throughSequence: durable.length - 1,
498+
rootTurn: {
499+
sessionId: 'session-1',
500+
turnId: 'turn-1',
501+
runId: 'run-1',
502+
status: 'completed',
503+
terminalEventId: 'terminal-1',
504+
},
505+
activeAssistantStreams: [],
506+
maxBytes: 512 * 1024,
507+
projection: 'owner',
508+
});
509+
510+
assert.ok(bootstrap.durable.fragments.length <= 256);
511+
assert.ok(bootstrap.durable.rawBytes <= 512 * 1024);
512+
assert.equal(bootstrap.durable.rangeBoundarySequence, null);
513+
assert.equal(bootstrap.durable.protectedTurnSequence, null);
514+
assert.ok(bootstrap.durable.nextCursor);
515+
});
516+
389517
test('admits a latest Turn exactly at the Host range message bound', async () => {
390518
const durable: StoredMessage[] = [
391519
{ ...assistantMessage(0), turnId: 'turn-before' },

packages/runtime-host/src/server/session-transcript-pager.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,11 @@ async function readRangeEdges(input: {
359359
targetBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES
360360
) {
361361
if (targetStart === 0) {
362-
throw new RangeError('Session transcript Turn range exceeds its capacity limit');
362+
return {
363+
selected: input.selected,
364+
rangeBoundarySequence: null,
365+
protectedTurnSequence: null,
366+
};
363367
}
364368
reachedFarEdge = true;
365369
break;
@@ -397,7 +401,11 @@ async function readRangeEdges(input: {
397401
groupBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES
398402
) {
399403
if (groupStart > 0) break;
400-
throw new RangeError('Session transcript Turn range exceeds its capacity limit');
404+
return {
405+
selected: input.selected,
406+
rangeBoundarySequence: null,
407+
protectedTurnSequence: null,
408+
};
401409
}
402410
if (
403411
retainedMessages + group.length > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES ||

0 commit comments

Comments
 (0)