@@ -59,6 +59,15 @@ const RING_CHECK_EVERY_BUSY_POLLS = 8
5959 */
6060const MAX_STREAM_MS = 60 * 60 * 1000 - 60_000
6161
62+ /**
63+ * Whether ring events read after `cursor` start right after it. The ring can trim its
64+ * head between a gap check and the read, and a read that starts later would silently
65+ * skip part of the turn.
66+ */
67+ function startsAfterCursor ( events : readonly { seq : number } [ ] , cursor : string ) : boolean {
68+ return events . length === 0 || events [ 0 ] . seq <= Number ( cursor || '0' ) + 1
69+ }
70+
6271function extractCanonicalRequestId ( value : unknown ) : string {
6372 return typeof value === 'string' && value . length > 0 ? value : ''
6473}
@@ -253,8 +262,10 @@ async function handleResumeRequestBody({
253262 return [ ]
254263 } ) ,
255264 ] )
256- // A reader the ring cannot serve is re-synced from the worker log by the live tail.
257- const batchEvents = fromLog || gap ? [ ] : events . map ( toStreamBatchEvent )
265+ // A reader the ring cannot serve, or whose next event it trimmed after the gap check,
266+ // is re-synced from the worker log by the live tail.
267+ const batchEvents =
268+ fromLog || gap || ! startsAfterCursor ( events , afterSeq ) ? [ ] : events . map ( toStreamBatchEvent )
258269 logger . info ( '[Resume] Batch response' , {
259270 streamId,
260271 afterCursor : afterSeq ,
@@ -287,9 +298,12 @@ async function handleResumeRequestBody({
287298 * no shared position to join on. The header tells the client to rebuild the turn
288299 * from an empty response, since the replay's cursors restart at 1.
289300 */
290- const gap = fromLog
301+ const ringGap = fromLog
291302 ? null
292303 : await findReplayGap ( streamId , afterCursor || '0' , extractRunRequestId ( run ) )
304+ // A finished run whose buffer expired answers its terminal; its transcript is persisted.
305+ const gap =
306+ ringGap && ! ( ringGap . latestSeq <= 0 && isTerminalStreamStatus ( run . status ) ) ? ringGap : null
293307 const resyncFromLog = fromLog || gap !== null
294308 let replayBody : ReadableStream < Uint8Array > | null = null
295309 /** Releases the worker's replay once this response ends; the request signal may never fire. */
@@ -380,8 +394,13 @@ async function handleResumeRequestBody({
380394 }
381395 request . signal . addEventListener ( 'abort' , abortListener , { once : true } )
382396
383- const flushEvents = async ( ) : Promise < number > => {
397+ /** Delivers the ring's events after the cursor, or returns null if it trimmed the next one. */
398+ const flushEvents = async ( ) : Promise < number | null > => {
384399 const events = await readEvents ( streamId , cursor )
400+ if ( ! startsAfterCursor ( events , cursor ) ) {
401+ logger . warn ( 'Replay ring trimmed past a reader cursor' , { streamId, cursor } )
402+ return null
403+ }
385404 if ( events . length > 0 ) {
386405 logger . debug ( '[Resume] Flushing events' , {
387406 streamId,
@@ -482,6 +501,7 @@ async function handleResumeRequestBody({
482501 }
483502
484503 let lastFlushed = await flushEvents ( )
504+ if ( lastFlushed === null ) return
485505
486506 let pollDelayMs = POLL_INTERVAL_MS
487507 while ( ! controllerClosed && Date . now ( ) - startTime < MAX_STREAM_MS ) {
@@ -493,14 +513,6 @@ async function handleResumeRequestBody({
493513 } )
494514 return null
495515 } )
496- // The ring lost its head or restarted under this tail; the re-attach re-syncs. Only a
497- // quiet ring can have restarted or have a dead controller that recovery re-sends
498- // under, so a busy tail checks every few polls rather than on each one.
499- const checkRing = lastFlushed === 0 || pollIterations % RING_CHECK_EVERY_BUSY_POLLS === 0
500- if ( checkRing && ! ringCanServe ( await readRingPosition ( streamId , cursor ) ) ) {
501- logger . warn ( 'Replay ring can no longer serve a live tail' , { streamId, cursor } )
502- break
503- }
504516 if ( ! currentRun ) {
505517 emitTerminalIfMissing ( MothershipStreamV1CompletionStatus . error , {
506518 message : 'The stream could not be recovered because its run metadata is unavailable.' ,
@@ -509,10 +521,23 @@ async function handleResumeRequestBody({
509521 } )
510522 break
511523 }
524+ // The ring lost its head, restarted or expired under this live tail; the re-attach
525+ // re-syncs, and a finished run answers its terminal instead. Only a quiet ring can
526+ // restart or be re-sent into by a recovery, so a busy tail checks every few polls.
527+ const checkRing = lastFlushed === 0 || pollIterations % RING_CHECK_EVERY_BUSY_POLLS === 0
528+ if (
529+ checkRing &&
530+ ! isTerminalStreamStatus ( currentRun . status ) &&
531+ ! ringCanServe ( await readRingPosition ( streamId , cursor ) )
532+ ) {
533+ logger . warn ( 'Replay ring can no longer serve a live tail' , { streamId, cursor } )
534+ break
535+ }
512536
513537 currentRequestId = extractRunRequestId ( currentRun ) || currentRequestId
514538
515539 const flushed = await flushEvents ( )
540+ if ( flushed === null ) break
516541 lastFlushed = flushed
517542 /* Adaptive tail: 4 Hz only while events are actually flowing; a quiet stream
518543 decays toward the cap so an attached client doesn't hammer Postgres + Redis
0 commit comments