소스 검색

fix: keep fallback out of background task sessions

Zerdeşt Taifour 1 개월 전
부모
커밋
e929ae8645

+ 31 - 0
src/hooks/foreground-fallback/index.test.ts

@@ -199,6 +199,37 @@ describe('ForegroundFallbackManager session.error', () => {
     expect(mocks.promptAsync).not.toHaveBeenCalled();
   });
 
+  test('does not detach fallback for externally owned sessions', async () => {
+    const ownedSessionIDs = new Set(['background-child-session']);
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true, {
+      shouldHandleSession: (sessionID) => !ownedSessionIDs.has(sessionID),
+    });
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'background-child-session',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          role: 'assistant',
+        },
+      },
+    });
+
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'background-child-session',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    expect(mocks.messages).not.toHaveBeenCalled();
+    expect(mocks.abort).not.toHaveBeenCalled();
+    expect(mocks.promptAsync).not.toHaveBeenCalled();
+  });
+
   test('does not abort when promptAsync is unavailable', async () => {
     const { client, mocks } = createMockClient({ includePromptAsync: false });
     const mgr = new ForegroundFallbackManager(client, makeChains(), true);

+ 17 - 0
src/hooks/foreground-fallback/index.ts

@@ -24,6 +24,16 @@ import {
 
 type OpencodeClient = PluginInput['client'];
 
+interface ForegroundFallbackOptions {
+  /**
+   * Return false for sessions whose execution lifecycle is owned elsewhere.
+   * Background task child sessions are owned by OpenCode's task/background
+   * runner; aborting and re-prompting them here detaches fallback work from the
+   * parent task completion notification.
+   */
+  shouldHandleSession?: (sessionID: string) => boolean;
+}
+
 // ---------------------------------------------------------------------------
 // Rate-limit detection
 // ---------------------------------------------------------------------------
@@ -98,6 +108,7 @@ export class ForegroundFallbackManager {
      */
     private readonly chains: Record<string, string[]>,
     private readonly enabled: boolean,
+    private readonly options: ForegroundFallbackOptions = {},
   ) {}
 
   /**
@@ -213,6 +224,12 @@ export class ForegroundFallbackManager {
 
   private async tryFallback(sessionID: string): Promise<void> {
     if (!sessionID) return;
+    if (this.options.shouldHandleSession?.(sessionID) === false) {
+      log('[foreground-fallback] skipped externally owned session', {
+        sessionID,
+      });
+      return;
+    }
     if (this.inProgress.has(sessionID)) return;
 
     // Deduplicate: multiple events can fire for a single rate-limit event.

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

@@ -7,6 +7,7 @@ function createHook(options?: {
   readContextMinLines?: number;
   readContextMaxFiles?: number;
   backgroundJobBoard?: BackgroundJobBoard;
+  managedTaskSessionIDs?: Set<string>;
   sessionStatus?: unknown;
 }) {
   const hook = createTaskSessionManagerHook(
@@ -24,6 +25,7 @@ function createHook(options?: {
       readContextMinLines: options?.readContextMinLines,
       readContextMaxFiles: options?.readContextMaxFiles,
       backgroundJobBoard: options?.backgroundJobBoard,
+      managedTaskSessionIDs: options?.managedTaskSessionIDs,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
     },
   );
@@ -1455,6 +1457,53 @@ describe('task-session-manager hook', () => {
     expect(prompt).toContain('(+1 more)');
   });
 
+  test('tracks managed child sessions before task launch output registers', async () => {
+    const managedTaskSessionIDs = new Set<string>();
+    const { hook } = createHook({ managedTaskSessionIDs });
+
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'child-1', parentID: 'parent-1' } },
+      },
+    });
+
+    expect(managedTaskSessionIDs.has('child-1')).toBe(true);
+  });
+
+  test('clears managed child sessions when child or parent is deleted', async () => {
+    const board = new BackgroundJobBoard();
+    const managedTaskSessionIDs = new Set<string>();
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      managedTaskSessionIDs,
+    });
+
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'child-1', parentID: 'parent-1' } },
+      },
+    });
+    board.registerLaunch({
+      taskID: 'child-2',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'architecture review',
+    });
+    managedTaskSessionIDs.add('child-2');
+
+    await hook.event({
+      event: { type: 'session.deleted', properties: { sessionID: 'child-1' } },
+    });
+    expect(managedTaskSessionIDs.has('child-1')).toBe(false);
+
+    await hook.event({
+      event: { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
+    });
+    expect(managedTaskSessionIDs.has('child-2')).toBe(false);
+  });
+
   test('reusable cap evicts only old reusable jobs, not active jobs', async () => {
     const board = new BackgroundJobBoard({ maxReusablePerAgent: 2 });
     for (const index of [1, 2, 3]) {

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

@@ -147,6 +147,7 @@ export function createTaskSessionManagerHook(
     readContextMinLines?: number;
     readContextMaxFiles?: number;
     backgroundJobBoard?: BackgroundJobBoard;
+    managedTaskSessionIDs?: Set<string>;
     shouldManageSession: (sessionID: string) => boolean;
   },
 ) {
@@ -715,6 +716,7 @@ export function createTaskSessionManagerHook(
           options.shouldManageSession(info.parentID)
         ) {
           pendingManagedTaskIds.add(info.id);
+          options.managedTaskSessionIDs?.add(info.id);
         }
         return;
       }
@@ -824,11 +826,16 @@ export function createTaskSessionManagerHook(
         },
       );
 
+      const childJobs = backgroundJobBoard.list(sessionId);
       backgroundJobBoard.drop(sessionId);
       backgroundJobBoard.clearParent(sessionId);
       terminalJobsInjectedByParent.delete(sessionId);
       contextByTask.delete(sessionId);
       pendingManagedTaskIds.delete(sessionId);
+      options.managedTaskSessionIDs?.delete(sessionId);
+      for (const childJob of childJobs) {
+        options.managedTaskSessionIDs?.delete(childJob.taskID);
+      }
       pruneContext();
 
       for (const [callId, pending] of pendingCalls.entries()) {

+ 8 - 0
src/index.ts

@@ -150,6 +150,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let reflectCommandHook: ReturnType<typeof createReflectCommandHook>;
   let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
   let backgroundJobBoard: BackgroundJobBoard;
+  let managedBackgroundTaskSessionIDs: Set<string>;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let presetManager: ReturnType<typeof createPresetManager>;
   let companionManager: CompanionManager;
@@ -253,6 +254,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
       readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
     });
+    managedBackgroundTaskSessionIDs = new Set<string>();
 
     // Initialize MultiplexerSessionManager to handle OpenCode's built-in
     // Task tool sessions
@@ -301,6 +303,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       runtimeChains,
       config.fallback?.enabled !== false &&
         Object.keys(runtimeChains).length > 0,
+      {
+        shouldHandleSession: (sessionID) =>
+          !backgroundJobBoard.get(sessionID) &&
+          !managedBackgroundTaskSessionIDs.has(sessionID),
+      },
     );
 
     deepworkCommandHook = createDeepworkCommandHook();
@@ -310,6 +317,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
       readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
       backgroundJobBoard,
+      managedTaskSessionIDs: managedBackgroundTaskSessionIDs,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
     });