Browse Source

Merge branch 'omos/pr-722-retry-fallback'

Alvin Unreal 4 weeks ago
parent
commit
2d6e266ed7

+ 195 - 23
src/hooks/foreground-fallback/index.test.ts

@@ -1,6 +1,10 @@
 import { beforeEach, describe, expect, mock, test } from 'bun:test';
 import { SessionLifecycle } from '../session-lifecycle';
-import { ForegroundFallbackManager, isRateLimitError } from './index';
+import {
+  ForegroundFallbackManager,
+  isFailoverError,
+  isRateLimitError,
+} from './index';
 
 type ForegroundFallbackClient = ConstructorParameters<
   typeof ForegroundFallbackManager
@@ -60,10 +64,23 @@ function makeChains(
 }
 
 // ---------------------------------------------------------------------------
-// isRateLimitError
+// isFailoverError
 // ---------------------------------------------------------------------------
 
-describe('isRateLimitError', () => {
+describe('isFailoverError', () => {
+  test('classifies recoverable HTTP 400 response bodies as failover errors', () => {
+    expect(
+      isFailoverError({
+        data: { statusCode: 400, responseBody: 'rate limit exceeded' },
+      }),
+    ).toBe(true);
+    expect(
+      isFailoverError({
+        data: { statusCode: 400, message: 'invalid request: missing field' },
+      }),
+    ).toBe(false);
+  });
+
   test('returns true for 429 status code', () => {
     expect(isRateLimitError({ data: { statusCode: 429 } })).toBe(true);
   });
@@ -388,6 +405,132 @@ describe('ForegroundFallbackManager message.updated', () => {
 // ---------------------------------------------------------------------------
 
 describe('ForegroundFallbackManager session.status', () => {
+  test('aborts active retry-budget-exhausted session before fallback re-prompt', async () => {
+    const calls: string[] = [];
+    const { client, mocks } = createMockClient({
+      abortImpl: async () => {
+        calls.push('abort');
+      },
+      promptAsyncImpl: async () => {
+        calls.push('promptAsync');
+        return {};
+      },
+    });
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-retry-abort-before-prompt',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+        },
+      },
+    });
+
+    for (const attempt of [1, 2, 3]) {
+      await mgr.handleEvent({
+        type: 'session.status',
+        properties: {
+          sessionID: 'sess-retry-abort-before-prompt',
+          status: {
+            type: 'retry',
+            attempt,
+            message: 'rate limit, retrying...',
+          },
+        },
+      });
+    }
+
+    expect(mocks.abort).toHaveBeenCalledTimes(1);
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(calls).toEqual(['abort', 'promptAsync']);
+  });
+
+  test('keeps registered child agent identity sticky for retry fallback chain', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains({
+        oracle: ['anthropic/claude-sonnet-4-5', 'openai/o3'],
+      }),
+      true,
+      1,
+    );
+
+    mgr.registerSessionAgent('child-oracle-sticky', 'oracle');
+    mgr.registerSessionAgent('child-oracle-sticky', 'orchestrator');
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'child-oracle-sticky',
+          providerID: 'anthropic',
+          modelID: 'claude-sonnet-4-5',
+        },
+      },
+    });
+
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'child-oracle-sticky',
+        status: { type: 'retry', message: 'usage limit reached, retrying...' },
+      },
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { body: { model: { providerID: string; modelID: string } } },
+    ];
+    expect(call[0].body.model).toEqual({ providerID: 'openai', modelID: 'o3' });
+  });
+
+  test('includes the sticky child agent in fallback promptAsync body', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains({
+        oracle: ['anthropic/claude-sonnet-4-5', 'openai/o3'],
+      }),
+      true,
+      1,
+    );
+
+    mgr.registerSessionAgent('child-oracle-agent-body', 'oracle');
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'child-oracle-agent-body',
+          providerID: 'anthropic',
+          modelID: 'claude-sonnet-4-5',
+        },
+      },
+    });
+
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'child-oracle-agent-body',
+        status: { type: 'retry', message: 'usage limit reached, retrying...' },
+      },
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      {
+        body: {
+          agent?: string;
+          model: { providerID: string; modelID: string };
+        };
+      },
+    ];
+    expect(call[0].body.agent).toBe('oracle');
+    expect(call[0].body.model).toEqual({ providerID: 'openai', modelID: 'o3' });
+  });
+
   test('triggers fallback on retry status with rate limit message', async () => {
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
@@ -455,7 +598,36 @@ describe('ForegroundFallbackManager session.status', () => {
     expect(mocks.promptAsync).not.toHaveBeenCalled();
   });
 
-  test('triggers fallback on first session.status retry (uses shouldIntervene)', async () => {
+  test('does not abort or switch after retries without a failover reason', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-retry-no-reason',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+        },
+      },
+    });
+
+    for (const attempt of [1, 2, 3]) {
+      await mgr.handleEvent({
+        type: 'session.status',
+        properties: {
+          sessionID: 'sess-retry-no-reason',
+          status: { type: 'retry', attempt },
+        },
+      });
+    }
+
+    expect(mocks.abort).not.toHaveBeenCalled();
+    expect(mocks.promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('absorbs failover retries until the retry budget is exhausted', async () => {
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
 
@@ -470,7 +642,7 @@ describe('ForegroundFallbackManager session.status', () => {
       },
     });
 
-    // First retry triggers fallback immediately — no budget absorption
+    // The first retry is absorbed; only exhaustion triggers a failover.
     await mgr.handleEvent({
       type: 'session.status',
       properties: {
@@ -482,10 +654,10 @@ describe('ForegroundFallbackManager session.status', () => {
         },
       },
     });
-    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
   });
 
-  test('second session.status retry goes through budget after first triggered fallback', async () => {
+  test('switches models after three failover retries', async () => {
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
 
@@ -500,7 +672,7 @@ describe('ForegroundFallbackManager session.status', () => {
       },
     });
 
-    // First retry triggers immediately
+    // First retry is absorbed.
     await mgr.handleEvent({
       type: 'session.status',
       properties: {
@@ -512,9 +684,9 @@ describe('ForegroundFallbackManager session.status', () => {
         },
       },
     });
-    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
 
-    // Second retry absorbed by budget (tried > 0 → checkRetryBudget)
+    // Second retry is also absorbed; the third exhausts the budget.
     await mgr.handleEvent({
       type: 'session.status',
       properties: {
@@ -537,12 +709,12 @@ describe('ForegroundFallbackManager session.status', () => {
         },
       },
     });
-    expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
   });
 
   test('triggers fallback when rate-limit text is in props.error instead of status.message', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -569,7 +741,7 @@ describe('ForegroundFallbackManager session.status', () => {
 
   test('triggers fallback when props.error is a plain string', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -609,7 +781,7 @@ describe('ForegroundFallbackManager session.status', () => {
       },
     });
 
-    // First rate-limit: triggers fallback, sessionRetries set to 1
+    // First rate-limit is absorbed.
     await mgr.handleEvent({
       type: 'session.status',
       properties: {
@@ -621,7 +793,7 @@ describe('ForegroundFallbackManager session.status', () => {
         },
       },
     });
-    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
 
     // Non-rate-limit status (e.g. abort side effect): must NOT reset retries.
     // If it did, the next rate-limit would see tried=0 and trigger immediate
@@ -633,9 +805,9 @@ describe('ForegroundFallbackManager session.status', () => {
         status: { type: 'retry', attempt: 1, message: 'aborted' },
       },
     });
-    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
 
-    // Second rate-limit: absorbed by budget (tried=1, not 0 from reset)
+    // Second rate-limit remains within the budget.
     await mgr.handleEvent({
       type: 'session.status',
       properties: {
@@ -647,9 +819,9 @@ describe('ForegroundFallbackManager session.status', () => {
         },
       },
     });
-    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
 
-    // Third rate-limit: budget exhausted at maxRetries-1=2, re-triggers
+    // Third rate-limit exhausts the budget.
     await mgr.handleEvent({
       type: 'session.status',
       properties: {
@@ -661,7 +833,7 @@ describe('ForegroundFallbackManager session.status', () => {
         },
       },
     });
-    expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
   });
 });
 
@@ -1294,13 +1466,13 @@ describe('ForegroundFallbackManager runtimeOverride', () => {
     expect(mocks.abort).toHaveBeenCalledTimes(1);
   });
 
-  test('session.status with runtimeOverride=false and out-of-chain model triggers immediate fallback which aborts', async () => {
+  test('session.status with runtimeOverride=false aborts an out-of-chain model after retry exhaustion', async () => {
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(
       client,
       makeChains(),
       true,
-      3, // maxRetries
+      1, // maxRetries
       undefined,
       false, // runtimeOverride
     );
@@ -1318,7 +1490,7 @@ describe('ForegroundFallbackManager runtimeOverride', () => {
       },
     });
 
-    // First retry triggers shouldIntervene → immediate fallback → abort
+    // The first failover retry exhausts this one-attempt budget.
     await mgr.handleEvent({
       type: 'session.status',
       properties: {

+ 156 - 38
src/hooks/foreground-fallback/index.ts

@@ -43,32 +43,119 @@ const RATE_LIMIT_PATTERNS = [
   /insufficient.?(quota|balance)/i,
   /high concurrency/i,
   /reduce concurrency/i,
-  // ponytail: transient server errors mixed in; rename to isRetryableError
-  // and split from rate-limit detection when this list grows further
-  /service unavailable/i,
   /monthly usage limit/i,
   /5-hour usage limit/i,
   /weekly usage limit/i,
 ];
 
-export function isRateLimitError(error: unknown): boolean {
+const OUTAGE_STATUS_CODES = new Set([500, 502, 503, 504]);
+const TRANSPORT_CODES = new Set([
+  'ECONNREFUSED',
+  'ECONNRESET',
+  'ENOTFOUND',
+  'ETIMEDOUT',
+  'EAI_AGAIN',
+]);
+const TRANSPORT_MESSAGE_PATTERNS = [
+  /^fetch failed$/i,
+  /^socket hang up$/i,
+  /^provider request timeout$/i,
+  /^request timeout$/i,
+  /^connect ECONNREFUSED\b/i,
+  /^getaddrinfo ENOTFOUND\b/i,
+];
+const PROVIDER_OUTAGE_PATTERNS = [
+  /\binternal server error\b/i,
+  /\bbad gateway\b/i,
+  /\bgateway timeout\b/i,
+  /\bservice unavailable\b/i,
+  /\bupstream outage\b/i,
+  /\bprovider outage\b/i,
+  /\bprovider unavailable\b/i,
+];
+
+function extractStatusCode(error: {
+  statusCode?: unknown;
+  data?: { statusCode?: unknown };
+}): number | undefined {
+  const value = error.statusCode ?? error.data?.statusCode;
+  return typeof value === 'number' ? value : undefined;
+}
+
+function eventSessionID(props: {
+  sessionID?: string;
+  info?: { id?: string };
+}): string | undefined {
+  return props.sessionID ?? props.info?.id;
+}
+
+export function isFailoverError(error: unknown): boolean {
   if (!error) return false;
-  // Handle string-typed errors (OpenCode may send a plain error string)
   if (typeof error === 'string') {
-    return RATE_LIMIT_PATTERNS.some((p) => p.test(error));
+    return (
+      RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(error)) ||
+      PROVIDER_OUTAGE_PATTERNS.some((pattern) => pattern.test(error)) ||
+      TRANSPORT_MESSAGE_PATTERNS.some((pattern) => pattern.test(error))
+    );
   }
   if (typeof error !== 'object') return false;
   const err = error as {
+    code?: unknown;
+    cause?: { code?: unknown };
     message?: string;
-    data?: { statusCode?: number; message?: string; responseBody?: string };
+    statusCode?: number;
+    data?: {
+      code?: unknown;
+      statusCode?: number;
+      message?: string;
+      responseBody?: string;
+    };
   };
+  const statusCode = extractStatusCode(err);
+  if (
+    statusCode === 429 ||
+    (statusCode !== undefined && OUTAGE_STATUS_CODES.has(statusCode))
+  ) {
+    return true;
+  }
+  if (
+    [err.code, err.cause?.code, err.data?.code].some(
+      (code) => typeof code === 'string' && TRANSPORT_CODES.has(code),
+    )
+  ) {
+    return true;
+  }
+
+  const messages = [
+    err.message ?? '',
+    err.data?.message ?? '',
+    err.data?.responseBody ?? '',
+  ];
+  if (
+    messages.some((message) =>
+      TRANSPORT_MESSAGE_PATTERNS.some((p) => p.test(message)),
+    )
+  ) {
+    return true;
+  }
+
   const text = [
     err.message ?? '',
-    String(err.data?.statusCode ?? ''),
     err.data?.message ?? '',
     err.data?.responseBody ?? '',
   ].join(' ');
-  return RATE_LIMIT_PATTERNS.some((p) => p.test(text));
+  const hasFailoverReason =
+    RATE_LIMIT_PATTERNS.some((p) => p.test(text)) ||
+    PROVIDER_OUTAGE_PATTERNS.some((p) => p.test(text));
+  // Providers sometimes return recoverable rate-limit/outage payloads with
+  // an HTTP 400 wrapper. Preserve application-level 400 failures, but let a
+  // recognizable failover body continue through the fallback path.
+  return hasFailoverReason;
+}
+
+/** @deprecated Use isFailoverError instead. */
+export function isRateLimitError(error: unknown): boolean {
+  return isFailoverError(error);
 }
 
 // ---------------------------------------------------------------------------
@@ -114,6 +201,18 @@ export class ForegroundFallbackManager {
     return this.inProgress.has(sessionID);
   }
 
+  registerSessionAgent(sessionID: string, agentName: string): void {
+    const normalizedAgentName = agentName.trim();
+    if (
+      !sessionID ||
+      !normalizedAgentName ||
+      this.sessionAgent.has(sessionID)
+    ) {
+      return;
+    }
+    this.sessionAgent.set(sessionID, normalizedAgentName);
+  }
+
   constructor(
     private readonly client: OpencodeClient,
     /**
@@ -166,7 +265,7 @@ export class ForegroundFallbackManager {
         if (!sessionID) break;
         // Capture agent name when available (OpenCode includes it on subagent messages)
         if (typeof info.agent === 'string') {
-          this.sessionAgent.set(sessionID, info.agent);
+          this.registerSessionAgent(sessionID, info.agent);
         }
         // Track the model currently serving this session
         if (
@@ -178,8 +277,8 @@ export class ForegroundFallbackManager {
             `${info.providerID}/${info.modelID}`,
           );
         }
-        // Rate-limit on an individual message
-        if (info.error && isRateLimitError(info.error)) {
+        // Failover-worthy error on an individual message
+        if (info.error && isFailoverError(info.error)) {
           if (this.shouldIntervene(sessionID)) {
             await this.tryFallback(sessionID);
           }
@@ -192,15 +291,17 @@ export class ForegroundFallbackManager {
 
       case 'session.error': {
         const props = event.properties as
-          | { sessionID?: string; error?: unknown }
+          | { sessionID?: string; info?: { id?: string }; error?: unknown }
           | undefined;
+        if (!props) break;
+        const sessionID = eventSessionID(props);
         if (
-          props?.sessionID &&
+          sessionID &&
           props.error &&
-          isRateLimitError(props.error) &&
-          this.shouldIntervene(props.sessionID)
+          isFailoverError(props.error) &&
+          this.shouldIntervene(sessionID)
         ) {
-          await this.tryFallback(props.sessionID);
+          await this.tryFallback(sessionID);
         }
         break;
       }
@@ -209,29 +310,29 @@ export class ForegroundFallbackManager {
         const props = event.properties as
           | {
               sessionID?: string;
+              info?: { id?: string };
               status?: { type?: string; message?: string; attempt?: number };
               error?: unknown;
             }
           | undefined;
-        if (!props?.sessionID) break;
-        const msg = props.status?.message?.toLowerCase() ?? '';
-        const isRateLimit =
-          (msg &&
-            (msg.includes('rate limit') ||
-              msg.includes('usage limit') ||
-              msg.includes('usage exceeded') ||
-              msg.includes('quota exceeded') ||
-              msg.includes('exceededbudget') ||
-              msg.includes('over budget') ||
-              msg.includes('insufficient') ||
-              msg.includes('high concurrency') ||
-              msg.includes('reduce concurrency'))) ||
-          isRateLimitError(props.error);
-        if (isRateLimit) {
-          if (this.shouldIntervene(props.sessionID)) {
-            await this.tryFallbackWithAbort(props.sessionID);
-            this.sessionRetries.set(props.sessionID, 1);
+        if (!props) break;
+        const sessionID = eventSessionID(props);
+        if (!sessionID) break;
+        const isFailoverRetry =
+          props.status?.type === 'retry' &&
+          (isFailoverError(props.error) ||
+            (props.status.message !== undefined &&
+              isFailoverError({ message: props.status.message })));
+        if (isFailoverRetry) {
+          if (this.checkRetryBudget(sessionID)) {
+            await this.tryFallbackWithAbort(sessionID);
           }
+          break;
+        }
+
+        if (this.isRecoveredStatus(props.status?.type)) {
+          // Recovered/terminal status: clear retry count.
+          this.sessionRetries.delete(sessionID);
         }
         // Note: do NOT clear sessionRetries here on non-rate-limit statuses.
         // Abort events triggered by our own fallback carry non-rate-limit
@@ -249,7 +350,7 @@ export class ForegroundFallbackManager {
           | { sessionID?: string; agentName?: unknown }
           | undefined;
         if (props?.sessionID && typeof props.agentName === 'string') {
-          this.sessionAgent.set(props.sessionID, props.agentName);
+          this.registerSessionAgent(props.sessionID, props.agentName);
         }
         break;
       }
@@ -301,6 +402,16 @@ export class ForegroundFallbackManager {
     return this.checkRetryBudget(sessionID);
   }
 
+  private isRecoveredStatus(statusType: string | undefined): boolean {
+    return (
+      statusType === 'idle' ||
+      statusType === 'complete' ||
+      statusType === 'completed' ||
+      statusType === 'success' ||
+      statusType === 'terminal'
+    );
+  }
+
   // ---------------------------------------------------------------------------
   // Core fallback logic
   // ---------------------------------------------------------------------------
@@ -482,6 +593,7 @@ export class ForegroundFallbackManager {
         promptAsync?: (args: {
           path: { id: string };
           body: {
+            agent?: string;
             parts: unknown[];
             model: { providerID: string; modelID: string };
           };
@@ -492,6 +604,12 @@ export class ForegroundFallbackManager {
         return;
       }
 
+      const promptBody = {
+        parts: lastUser.parts,
+        model: ref,
+        ...(agentName ? { agent: agentName } : {}),
+      };
+
       // Try queuing the fallback prompt without aborting first. If OpenCode
       // accepts it (204), the fallback model replaces the retry loop
       // transparently — no dialog, no session error shown to the user.
@@ -499,7 +617,7 @@ export class ForegroundFallbackManager {
       try {
         await sessionClient.promptAsync({
           path: { id: sessionID },
-          body: { parts: lastUser.parts, model: ref },
+          body: promptBody,
         });
       } catch (_promptErr) {
         log('[foreground-fallback] promptAsync on busy session, aborting', {
@@ -509,7 +627,7 @@ export class ForegroundFallbackManager {
         await new Promise((r) => setTimeout(r, REPROMPT_DELAY_MS));
         await sessionClient.promptAsync({
           path: { id: sessionID },
-          body: { parts: lastUser.parts, model: ref },
+          body: promptBody,
         });
       }
 

+ 1 - 0
src/hooks/index.ts

@@ -7,6 +7,7 @@ export { createDelegateTaskRetryHook } from './delegate-task-retry/hook';
 export { createFilterAvailableSkillsHook } from './filter-available-skills';
 export {
   ForegroundFallbackManager,
+  isFailoverError,
   isRateLimitError,
 } from './foreground-fallback';
 export { processImageAttachments } from './image-hook';

+ 39 - 0
src/hooks/task-session-manager/index.test.ts

@@ -1441,6 +1441,45 @@ describe('task-session-manager hook', () => {
     });
   });
 
+  test('preserves injected terminal jobs for recoverable HTTP 400 errors', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'completed' });
+
+    const messages = createMessages('parent-1', 'continue');
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    await hook.event({
+      event: {
+        type: 'session.error',
+        properties: {
+          sessionID: 'parent-1',
+          error: {
+            data: { statusCode: 400, responseBody: 'rate limit exceeded' },
+          } as unknown as { name?: string },
+        },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+    });
+  });
+
   test('completed reconciled job appears reusable and resumes via task', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });

+ 2 - 2
src/hooks/task-session-manager/index.ts

@@ -11,7 +11,7 @@ import {
 } from '../../utils';
 import { isRecord as isObjectRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
-import { isRateLimitError } from '../foreground-fallback/index';
+import { isFailoverError } from '../foreground-fallback/index';
 import type { SessionLifecycle } from '../session-lifecycle';
 import {
   isUserMessageWithParts,
@@ -737,7 +737,7 @@ export function createTaskSessionManagerHook(
           const props = input.event.properties as
             | { error?: unknown }
             | undefined;
-          if (!props?.error || !isRateLimitError(props.error)) {
+          if (!props?.error || !isFailoverError(props.error)) {
             terminalJobsInjectedByParent.delete(sessionId);
           }
         }

+ 1 - 0
src/index.ts

@@ -1057,6 +1057,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
 
       if (agent) {
+        foregroundFallback.registerSessionAgent(input.sessionID, agent);
         sessionAgentMap.set(input.sessionID, agent);
         // A chat message means this session is actively working. This also
         // covers the race where session.status busy fires before the