@@ -580,6 +580,12 @@ const scenario = (
580580 } ) ,
581581 )
582582
583+ // Nominal retry gaps: exponential from 2s capped at 10s, for 10 retries.
584+ const RETRY_GAPS = [ 2_000 , 4_000 , 8_000 , ...Array < number > ( 7 ) . fill ( 10_000 ) ]
585+ // Longest possible gap per retry (+20% jitter); advancing the clock by these always fires the retry.
586+ const RETRY_GAPS_MAX = RETRY_GAPS . map ( ( gap ) => gap * 1.2 )
587+ const RETRY_ATTEMPTS = RETRY_GAPS . map ( ( _ , index ) => index + 2 )
588+
583589// Subscribe before resuming; model requests can arrive before retry backoff is scheduled.
584590const subscribeRetries = ( s : Scenario ) =>
585591 Effect . gen ( function * ( ) {
@@ -2676,8 +2682,8 @@ describe("SessionRunnerLLM", () => {
26762682 const compaction = yield * s . session . compact ( { sessionID } )
26772683 yield * s . resume
26782684
2679- expect ( attempts ) . toEqual ( [ 2 , 3 , 4 , 5 ] )
2680- expect ( s . requests ) . toHaveLength ( 6 )
2685+ expect ( attempts ) . toEqual ( RETRY_ATTEMPTS )
2686+ expect ( s . requests ) . toHaveLength ( RETRY_ATTEMPTS . length + 2 )
26812687 expect ( ( yield * s . messages ) . find ( ( message ) => message . id === compaction . id ) ) . toMatchObject ( {
26822688 status : "failed" ,
26832689 error : { type : "provider.transport" , message : "Provider unavailable" } ,
@@ -5476,28 +5482,34 @@ describe("SessionRunnerLLM", () => {
54765482 LLMEvent . textStart ( { id : "mixed-partial" } ) ,
54775483 LLMEvent . textDelta ( { id : "mixed-partial" , text : "Partial" } ) ,
54785484 )
5479- yield * s . llm . push ( Stream . fail ( failure ) , partial , Stream . fail ( failure ) , partial , partial )
5485+ // Alternate transparent failures and partial continuations until the retry allowance is spent.
5486+ const outcomes = RETRY_GAPS . map ( ( _ , index ) => ( index % 2 === 0 ? Stream . fail ( failure ) : partial ) )
5487+ yield * s . llm . push ( ...outcomes , partial )
54805488 const run = yield * s . resume . pipe ( Effect . forkChild )
54815489 const identities : SessionMessage . ID [ ] = [ ]
5482- for ( const delay of [ 2_400 , 4_800 , 9_600 , 19_200 ] ) {
5490+ for ( const delay of RETRY_GAPS_MAX ) {
54835491 identities . push ( yield * Queue . take ( scheduled ) )
54845492 yield * TestClock . adjust ( delay )
54855493 }
54865494 expect ( yield * Fiber . join ( run ) . pipe ( Effect . flip ) ) . toBe ( failure )
5487- expect ( s . requests ) . toHaveLength ( 5 )
5488- expect ( identities [ 0 ] ) . toBe ( identities [ 1 ] )
5489- expect ( identities [ 2 ] ) . toBe ( identities [ 3 ] )
5490- expect ( identities [ 0 ] ) . not . toBe ( identities [ 2 ] )
5495+ expect ( s . requests ) . toHaveLength ( RETRY_GAPS . length + 1 )
5496+ // A transparent retry keeps the assistant identity; a partial continuation starts a new one.
5497+ for ( const [ index , identity ] of identities . entries ( ) ) {
5498+ if ( index === 0 ) continue
5499+ if ( index % 2 === 1 ) expect ( identity ) . toBe ( identities [ index - 1 ] )
5500+ else expect ( identity ) . not . toBe ( identities [ index - 1 ] )
5501+ }
5502+ const partials = outcomes . filter ( ( outcome ) => outcome === partial ) . length
54915503 const messages = yield * s . context
5492- expect ( messages . filter ( ( message ) => message . type === "assistant" ) ) . toHaveLength ( 3 )
5493- expect ( messages . filter ( ( message ) => message . type === "synthetic" ) ) . toHaveLength ( 2 )
5504+ expect ( messages . filter ( ( message ) => message . type === "assistant" ) ) . toHaveLength ( partials + 1 )
5505+ expect ( messages . filter ( ( message ) => message . type === "synthetic" ) ) . toHaveLength ( partials )
54945506 const events = yield * recordedEventTypes ( sessionID )
5495- expect ( events . filter ( ( type ) => type === "session.retry.scheduled.1" ) ) . toHaveLength ( 4 )
5496- expect ( events . filter ( ( type ) => type === "session.step.failed.1" ) ) . toHaveLength ( 3 )
5507+ expect ( events . filter ( ( type ) => type === "session.retry.scheduled.1" ) ) . toHaveLength ( RETRY_GAPS . length )
5508+ expect ( events . filter ( ( type ) => type === "session.step.failed.1" ) ) . toHaveLength ( partials + 1 )
54975509 } ,
54985510 )
54995511
5500- scenario ( "stops incomplete stream continuations after five total attempts " , function * ( s ) {
5512+ scenario ( "stops incomplete stream continuations once the retry allowance is spent " , function * ( s ) {
55015513 yield * s . admit ( "Exhaust partial continuations" )
55025514 const failure = incompleteStream ( )
55035515 yield * s . llm . always (
@@ -5511,30 +5523,30 @@ describe("SessionRunnerLLM", () => {
55115523
55125524 const scheduled = yield * subscribeRetries ( s )
55135525 const run = yield * s . resume . pipe ( Effect . forkChild )
5514- for ( const delay of [ 2_400 , 4_800 , 9_600 , 19_200 ] ) {
5526+ for ( const delay of RETRY_GAPS_MAX ) {
55155527 yield * Queue . take ( scheduled )
55165528 yield * TestClock . adjust ( delay )
55175529 }
55185530 expect ( yield * Fiber . join ( run ) . pipe ( Effect . flip ) ) . toBe ( failure )
5519- expect ( s . requests ) . toHaveLength ( 5 )
5531+ expect ( s . requests ) . toHaveLength ( RETRY_GAPS . length + 1 )
55205532 const context = yield * s . context
5521- expect ( context . filter ( ( message ) => message . type === "assistant" ) ) . toHaveLength ( 5 )
5522- expect ( context . filter ( ( message ) => message . type === "synthetic" ) ) . toHaveLength ( 4 )
5533+ expect ( context . filter ( ( message ) => message . type === "assistant" ) ) . toHaveLength ( RETRY_GAPS . length + 1 )
5534+ expect ( context . filter ( ( message ) => message . type === "synthetic" ) ) . toHaveLength ( RETRY_GAPS . length )
55235535 } )
55245536
5525- scenario ( "stops after five total retry attempts " , function * ( s ) {
5537+ scenario ( "stops once the retry allowance is spent " , function * ( s ) {
55265538 yield * s . admit ( "Exhaust retries" )
55275539 const failure = providerUnavailable ( )
55285540 yield * s . llm . always ( Stream . fail ( failure ) )
55295541
55305542 const scheduled = yield * subscribeRetries ( s )
55315543 const run = yield * s . resume . pipe ( Effect . forkChild )
5532- for ( const delay of [ 2_400 , 4_800 , 9_600 , 19_200 ] ) {
5544+ for ( const delay of RETRY_GAPS_MAX ) {
55335545 yield * Queue . take ( scheduled )
55345546 yield * TestClock . adjust ( delay )
55355547 }
55365548 expect ( yield * Fiber . join ( run ) . pipe ( Effect . flip ) ) . toBe ( failure )
5537- expect ( s . requests ) . toHaveLength ( 5 )
5549+ expect ( s . requests ) . toHaveLength ( RETRY_GAPS . length + 1 )
55385550
55395551 const retries = yield * s . db
55405552 . select ( { data : EventTable . data } )
@@ -5543,22 +5555,20 @@ describe("SessionRunnerLLM", () => {
55435555 . orderBy ( asc ( EventTable . seq ) )
55445556 . all ( )
55455557 . pipe ( Effect . orDie )
5546- for ( const [ index , range ] of [
5547- [ 1_600 , 2_400 ] ,
5548- [ 4_800 , 7_200 ] ,
5549- [ 11_200 , 16_800 ] ,
5550- [ 24_000 , 36_000 ] ,
5551- ] . entries ( ) ) {
5552- expect ( retries [ index ] ?. data . at ) . toBeGreaterThanOrEqual ( range [ 0 ] ! )
5553- expect ( retries [ index ] ?. data . at ) . toBeLessThanOrEqual ( range [ 1 ] ! )
5558+ // Each scheduled time falls within the jittered cumulative window for that retry.
5559+ expect ( retries ) . toHaveLength ( RETRY_GAPS . length )
5560+ let elapsed = 0
5561+ for ( const [ index , gap ] of RETRY_GAPS . entries ( ) ) {
5562+ elapsed += gap
5563+ expect ( retries [ index ] ?. data . at ) . toBeGreaterThanOrEqual ( elapsed * 0.8 )
5564+ expect ( retries [ index ] ?. data . at ) . toBeLessThanOrEqual ( elapsed * 1.2 )
55545565 }
5555- expect ( ( yield * recordedEventTypes ( sessionID ) ) . filter ( ( type ) => type === "session.step.started.1" ) ) . toHaveLength ( 5 )
5566+ expect ( ( yield * recordedEventTypes ( sessionID ) ) . filter ( ( type ) => type === "session.step.started.1" ) ) . toHaveLength (
5567+ RETRY_GAPS . length + 1 ,
5568+ )
55565569 const assistant = requireAssistant ( yield * s . context )
55575570 expect ( yield * recordedStepSettlementEvents ( sessionID , assistant . id ) ) . toMatchObject ( [
5558- { type : "session.step.started.1" } ,
5559- { type : "session.step.started.1" } ,
5560- { type : "session.step.started.1" } ,
5561- { type : "session.step.started.1" } ,
5571+ ...RETRY_GAPS . map ( ( ) => ( { type : "session.step.started.1" } ) ) ,
55625572 { type : "session.step.started.1" } ,
55635573 { type : "session.step.failed.1" } ,
55645574 ] )
0 commit comments