Forráskód Böngészése

fix: abort session between fallback model attempts to unblock chain (#171)

* fix: abort session between fallback model attempts to unblock chain

When a model prompt fails or times out, the session stays busy
server-side because the running prompt is never cancelled. Each
subsequent fallback attempt sends a new prompt to the same busy
session, which gets rejected — making the entire fallback chain
ineffective for all 6 agents.

Two fixes applied:

1. promptWithTimeout now calls session.abort() when the timeout
   fires, cancelling the server-side prompt instead of only
   rejecting the client-side Promise.race.

2. The fallback loop in startTask now calls session.abort() with
   a 500ms settle delay between each failed attempt, ensuring the
   session is idle before the next model is tried.

Also adds per-attempt logging so fallback progression is visible
in debug output.

* fix: handle unhandled promise rejection in promptWithTimeout and make retry delay configurable

- Add .catch() to prompt promise before Promise.race to prevent late
  server-side rejections from crashing the process (Node ≥15 / Bun)
- Add fallback.retryDelayMs config option (default: 500) so the
  inter-attempt delay is tunable without touching source code
- Tests use retryDelayMs: 0 with a 10ms yield instead of 700ms
  wall-clock sleeps, making them 70x faster and deterministic
John Michael Vincent Bambico 4 hónapja
szülő
commit
d7bbfe7b62

+ 10 - 4
src/background/background-manager.test.ts

@@ -509,6 +509,7 @@ describe('BackgroundTaskManager', () => {
         fallback: {
           enabled: true,
           timeoutMs: 15000,
+          retryDelayMs: 0,
           chains: {
             explorer: ['openai/gpt-5.4', 'opencode/gpt-5-nano'],
           },
@@ -522,12 +523,14 @@ describe('BackgroundTaskManager', () => {
         parentSessionId: 'parent-123',
       });
 
-      await Promise.resolve();
-      await Promise.resolve();
+      // Yield to let the fire-and-forget async chain complete
+      // (retryDelayMs: 0 eliminates the inter-attempt delay)
       await new Promise((r) => setTimeout(r, 10));
 
       expect(task.status).toBe('running');
       expect(promptCalls).toBe(2);
+      // Verify session.abort was called between attempts
+      expect(ctx.client.session.abort).toHaveBeenCalled();
     });
 
     test('fails task when all fallback models fail', async () => {
@@ -546,6 +549,7 @@ describe('BackgroundTaskManager', () => {
         fallback: {
           enabled: true,
           timeoutMs: 15000,
+          retryDelayMs: 0,
           chains: {
             explorer: ['openai/gpt-5.4', 'opencode/gpt-5-nano'],
           },
@@ -559,12 +563,14 @@ describe('BackgroundTaskManager', () => {
         parentSessionId: 'parent-123',
       });
 
-      await Promise.resolve();
-      await Promise.resolve();
+      // Yield to let the fire-and-forget async chain complete
+      // (retryDelayMs: 0 eliminates the inter-attempt delay)
       await new Promise((r) => setTimeout(r, 10));
 
       expect(task.status).toBe('failed');
       expect(task.error).toContain('All fallback models failed');
+      // Verify session.abort was called: once between attempts + once in completeTask
+      expect(ctx.client.session.abort).toHaveBeenCalledTimes(2);
     });
 
     test('extracts content from multiple types and messages', async () => {

+ 61 - 14
src/background/background-manager.ts

@@ -273,14 +273,34 @@ export class BackgroundTaskManager {
       return;
     }
 
-    await Promise.race([
-      this.client.session.prompt(args),
-      new Promise<never>((_, reject) => {
-        setTimeout(() => {
-          reject(new Error(`Prompt timed out after ${timeoutMs}ms`));
-        }, timeoutMs);
-      }),
-    ]);
+    const sessionId = args.path.id;
+    let timer: ReturnType<typeof setTimeout> | undefined;
+
+    try {
+      // Attach a no-op .catch() so that when the timeout fires and
+      // session.abort() causes the prompt to reject after the race has
+      // already settled, the late rejection does not become unhandled
+      // (which would crash the process in Node ≥15 / Bun).
+      const promptPromise = this.client.session.prompt(args);
+      promptPromise.catch(() => {});
+
+      await Promise.race([
+        promptPromise,
+        new Promise<never>((_, reject) => {
+          timer = setTimeout(() => {
+            // Abort the running prompt so the session is no longer busy.
+            // Without this, session.prompt() continues running server-side
+            // and blocks subsequent fallback attempts on the same session.
+            this.client.session
+              .abort({ path: { id: sessionId } })
+              .catch(() => {});
+            reject(new Error(`Prompt timed out after ${timeoutMs}ms`));
+          }, timeoutMs);
+        }),
+      ]);
+    } finally {
+      clearTimeout(timer);
+    }
   }
 
   /**
@@ -363,6 +383,7 @@ export class BackgroundTaskManager {
       const timeoutMs = fallbackEnabled
         ? (this.config?.fallback?.timeoutMs ?? FALLBACK_FAILOVER_TIMEOUT_MS)
         : 0; // 0 = no timeout when fallback disabled
+      const retryDelayMs = this.config?.fallback?.retryDelayMs ?? 500;
       const chain = fallbackEnabled
         ? this.resolveFallbackChain(task.agent)
         : [];
@@ -370,8 +391,11 @@ export class BackgroundTaskManager {
 
       const errors: string[] = [];
       let succeeded = false;
+      const sessionId = session.data.id;
 
-      for (const model of attemptModels) {
+      for (let i = 0; i < attemptModels.length; i++) {
+        const model = attemptModels[i];
+        const modelLabel = model ?? 'default-model';
         try {
           const body: PromptBody = {
             ...basePromptBody,
@@ -386,9 +410,16 @@ export class BackgroundTaskManager {
             body.model = ref;
           }
 
+          if (i > 0) {
+            log(
+              `[background-manager] fallback attempt ${i + 1}/${attemptModels.length}: ${modelLabel}`,
+              { taskId: task.id },
+            );
+          }
+
           await this.promptWithTimeout(
             {
-              path: { id: session.data.id },
+              path: { id: sessionId },
               body,
               query: promptQuery,
             },
@@ -399,10 +430,26 @@ export class BackgroundTaskManager {
           break;
         } catch (error) {
           const msg = error instanceof Error ? error.message : String(error);
-          if (model) {
-            errors.push(`${model}: ${msg}`);
-          } else {
-            errors.push(`default-model: ${msg}`);
+          errors.push(`${modelLabel}: ${msg}`);
+          log(`[background-manager] model failed: ${modelLabel} — ${msg}`, {
+            taskId: task.id,
+          });
+
+          // Abort the session before trying the next model.
+          // The previous prompt may still be running server-side;
+          // without aborting, the session stays busy and rejects
+          // subsequent prompts, breaking the entire fallback chain.
+          if (i < attemptModels.length - 1) {
+            try {
+              await this.client.session.abort({
+                path: { id: sessionId },
+              });
+              // Allow server time to finalize the abort before
+              // the next prompt attempt (matches reference impl).
+              await new Promise((r) => setTimeout(r, retryDelayMs));
+            } catch {
+              // Session may already be idle; safe to ignore.
+            }
           }
         }
       }

+ 1 - 0
src/config/schema.ts

@@ -142,6 +142,7 @@ export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>;
 export const FailoverConfigSchema = z.object({
   enabled: z.boolean().default(true),
   timeoutMs: z.number().min(0).default(15000),
+  retryDelayMs: z.number().min(0).default(500),
   chains: FallbackChainsSchema.default({}),
 });