@@ -360,6 +360,44 @@ describe("runWithModelFallback – probe logic", () => {
360360 await expectPrimarySkippedAfterLongCooldown ( "billing" ) ;
361361 } ) ;
362362
363+ it ( "re-probes a single-provider primary blocked by a far-future subscription_limit (#90702)" , ( ) => {
364+ // fallbacks:[] + a multi-day subscription_limit reset must still re-probe on
365+ // the throttle instead of suspending until blockedUntil literally arrives,
366+ // since the rolling cap usually recovers earlier. Multi-fallback setups keep
367+ // preferring the fallback chain (covered above).
368+ const sixDays = 6 * 24 * 60 * 60 * 1000 ;
369+ const usageStats = {
370+ "openai-profile-1" : {
371+ blockedUntil : NOW + sixDays ,
372+ blockedReason : "subscription_limit" ,
373+ blockedSource : "wham" ,
374+ } ,
375+ } satisfies AuthProfileStore [ "usageStats" ] ;
376+
377+ expect (
378+ resolveOpenAiCooldownDecision ( {
379+ reason : "rate_limit" ,
380+ soonest : NOW + sixDays ,
381+ hasFallbackCandidates : false ,
382+ usageStats,
383+ } ) ,
384+ ) . toEqual ( { type : "attempt" , reason : "rate_limit" , markProbe : true } ) ;
385+
386+ // The 30s probe throttle is still honored so recovery probing cannot hammer
387+ // the upstream: a recent probe on the same key suspends until the slot opens.
388+ probeThrottleInternals . lastProbeAttempt . set ( "recent-openai" , NOW - 10_000 ) ;
389+ expectOpenAiProbeSuspension (
390+ resolveOpenAiCooldownDecision ( {
391+ reason : "rate_limit" ,
392+ soonest : NOW + sixDays ,
393+ hasFallbackCandidates : false ,
394+ throttleKey : "recent-openai" ,
395+ usageStats,
396+ } ) ,
397+ "rate_limit" ,
398+ ) ;
399+ } ) ;
400+
363401 it ( "decides when cooldowned primary probes are allowed" , ( ) => {
364402 expect (
365403 resolveOpenAiCooldownDecision ( {
@@ -674,7 +712,7 @@ describe("runWithModelFallback – probe logic", () => {
674712 }
675713 } ) ;
676714
677- it ( "single candidate skips with rate_limit and exhausts candidates " , async ( ) => {
715+ it ( "re-probes a single-provider rate-limited primary instead of suspending " , async ( ) => {
678716 const cfg = makeCfg ( {
679717 agents : {
680718 defaults : {
@@ -686,22 +724,26 @@ describe("runWithModelFallback – probe logic", () => {
686724 } ,
687725 } as Partial < OpenClawConfig > ) ;
688726
689- const almostExpired = NOW + 30 * 1000 ;
690- mockedGetSoonestCooldownExpiry . mockReturnValue ( almostExpired ) ;
727+ // Far-future cooldown with no fallback chain: the primary must still be
728+ // probed so a recovered rolling cap resumes work instead of staying silent
729+ // until blockedUntil arrives. See #90702.
730+ mockedGetSoonestCooldownExpiry . mockReturnValue ( NOW + 6 * 24 * 60 * 60 * 1000 ) ;
691731
692- const run = vi . fn ( ) . mockResolvedValue ( "unreachable " ) ;
732+ const run = vi . fn ( ) . mockResolvedValue ( "probed-ok " ) ;
693733
694- await expect (
695- runWithModelFallback ( {
696- cfg,
697- provider : "openai" ,
698- model : "gpt-4.1-mini" ,
699- fallbacksOverride : [ ] ,
700- run,
701- } ) ,
702- ) . rejects . toThrow ( "All models failed" ) ;
734+ const result = await runWithModelFallback ( {
735+ cfg,
736+ provider : "openai" ,
737+ model : "gpt-4.1-mini" ,
738+ fallbacksOverride : [ ] ,
739+ run,
740+ } ) ;
703741
704- expect ( run ) . not . toHaveBeenCalled ( ) ;
742+ expect ( result . result ) . toBe ( "probed-ok" ) ;
743+ expect ( run ) . toHaveBeenCalledTimes ( 1 ) ;
744+ expect ( run ) . toHaveBeenCalledWith ( "openai" , "gpt-4.1-mini" , {
745+ allowTransientCooldownProbe : true ,
746+ } ) ;
705747 } ) ;
706748
707749 it ( "scopes probe throttling by agentDir to avoid cross-agent suppression" , ( ) => {
0 commit comments