Kaynağa Gözat

feat(fallback): add configurable initialRetryDelayMs and retryDelayMs

Add two new optional fields to the fallback config:

- initialRetryDelayMs (default 0): delay before the first fallback on a
  rate-limit error. Gives intercepting plugins time to recover the current
  model before the fallback chain advances.

- retryDelayMs (default 500): delay between consecutive fallback attempts
  after the initial trigger.

retryDelayMs was available in v1.x but stripped as a legacy key in v2.x.
This commit restores it and adds initialRetryDelayMs for finer control.

Closes #1126
BaconDroid 2 hafta önce
ebeveyn
işleme
d42c5bab6d

+ 15 - 1
oh-my-opencode-slim.schema.json

@@ -1166,7 +1166,7 @@
           "properties": {
             "defaultConcurrency": {
               "default": 0,
-              "description": "Maximum concurrently running native background tasks. 0 means unlimited.",
+              "description": "Maximum concurrently running native background tasks. 0 disables the default cap.",
               "type": "integer",
               "minimum": 0,
               "maximum": 1000
@@ -1222,6 +1222,20 @@
           "type": "integer",
           "minimum": 0,
           "maximum": 9007199254740991
+        },
+        "initialRetryDelayMs": {
+          "default": 0,
+          "description": "Delay in milliseconds before triggering the first fallback on a rate-limit error. Gives intercepting plugins time to recover the current model before the fallback chain advances. 0 disables.",
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 9007199254740991
+        },
+        "retryDelayMs": {
+          "default": 500,
+          "description": "Delay in milliseconds between consecutive fallback attempts after the initial trigger. 0 disables.",
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 9007199254740991
         }
       },
       "additionalProperties": false

+ 2 - 0
src/config/runtime.ts

@@ -93,6 +93,8 @@ const DEFAULT_BACKGROUND_JOBS: BackgroundJobsConfig = {
 const DEFAULT_FALLBACK: FailoverConfig = {
   enabled: true,
   maxRetries: 3,
+  initialRetryDelayMs: 0,
+  retryDelayMs: 500,
 };
 
 /** First model from an override's model field (string or array). */

+ 19 - 1
src/config/schema.ts

@@ -288,7 +288,6 @@ export type BackgroundJobsConfig = z.infer<typeof BackgroundJobsConfigSchema>;
  */
 export const LEGACY_FALLBACK_KEYS = [
   'timeoutMs',
-  'retryDelayMs',
   'retry_on_empty',
   'runtimeOverride',
 ] as const;
@@ -326,6 +325,25 @@ export const FailoverConfigSchema = z.preprocess(
             'same model before aborting (or swapping to the next fallback ' +
             'model when a chain is configured).',
         ),
+      initialRetryDelayMs: z
+        .number()
+        .int()
+        .min(0)
+        .default(0)
+        .describe(
+          'Delay in milliseconds before triggering the first fallback on a ' +
+            'rate-limit error. Gives intercepting plugins time to recover ' +
+            'the current model before the fallback chain advances. 0 disables.',
+        ),
+      retryDelayMs: z
+        .number()
+        .int()
+        .min(0)
+        .default(500)
+        .describe(
+          'Delay in milliseconds between consecutive fallback attempts ' +
+            'after the initial trigger. 0 disables.',
+        ),
     })
     .strict(),
 );

+ 32 - 2
src/hooks/foreground-fallback/index.ts

@@ -374,6 +374,10 @@ export class ForegroundFallbackManager {
     private readonly maxRetries: number = 3,
     coordinator?: SessionLifecycle,
     onSessionModelChanged?: (sessionID: string, model: string) => void,
+    /** Delay before first fallback; gives intercepting plugins time to recover. */
+    private readonly initialRetryDelayMs: number = 0,
+    /** Delay between consecutive fallback attempts. */
+    private readonly retryDelayMs: number = 500,
   ) {
     this.onSessionModelChanged = onSessionModelChanged;
     if (coordinator) {
@@ -582,7 +586,21 @@ export class ForegroundFallbackManager {
    *  delegate to retry budget. Used by all three event paths. */
   private shouldTriggerFallback(sessionID: string): boolean {
     const tried = this.sessionRetries.get(sessionID) ?? 0;
-    if (tried === 0) return true;
+    if (tried === 0) {
+      if (this.initialRetryDelayMs > 0) {
+        this.sessionRetries.set(sessionID, tried + 1);
+        log('[foreground-fallback] delaying initial fallback', {
+          sessionID,
+          delayMs: this.initialRetryDelayMs,
+        });
+        setTimeout(() => {
+          this.sessionRetries.delete(sessionID);
+          void this.tryFallback(sessionID);
+        }, this.initialRetryDelayMs);
+        return false;
+      }
+      return true;
+    }
     return this.consumeRetryBudget(sessionID);
   }
 
@@ -593,7 +611,7 @@ export class ForegroundFallbackManager {
   private async tryFallback(sessionID: string, error?: unknown): Promise<void> {
     if (!sessionID) return;
     if (this.inProgress.has(sessionID)) return;
-    // No chain  no fallback. Skip before dedup so we don't stamp lastTrigger
+    // No chain -> no fallback. Skip before dedup so we don't stamp lastTrigger
     // for sessions we will never re-prompt (e.g. councillor via CouncilManager).
     if (!this.hasFallbackChain(sessionID)) return;
 
@@ -602,6 +620,18 @@ export class ForegroundFallbackManager {
     // model's failure is a separate incident and the cascade should continue.
     if (this.isDeduped(sessionID)) return;
 
+    // Delay between consecutive fallback attempts (except for the initial trigger
+    // which uses initialRetryDelayMs in shouldTriggerFallback).
+    const tried = this.sessionRetries.get(sessionID) ?? 0;
+    if (tried > 0 && this.retryDelayMs > 0) {
+      log('[foreground-fallback] delaying retry fallback', {
+        sessionID,
+        delayMs: this.retryDelayMs,
+        attempt: tried,
+      });
+      await new Promise((r) => setTimeout(r, this.retryDelayMs));
+    }
+
     this.inProgress.add(sessionID);
     try {
       await this.execFallback(sessionID, error);

+ 2 - 0
src/index.ts

@@ -471,6 +471,8 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // model. No-op for unknown/non-task sessions; idempotent per model.
       (sessionID, model) =>
         backgroundTaskConcurrency.migrateTask(sessionID, model),
+      runtime.fallback.initialRetryDelayMs,
+      runtime.fallback.retryDelayMs,
     );
 
     deepworkCommandHook = createDeepworkCommandHook();