فهرست منبع

fix(task-session-manager): record background subagent session.error on board (issue #836)

Child subagent sessions are not orchestrators, so the session.error handler's
shouldManageSession guard skipped recording their failures on the job board.
The job stayed 'running' and idle reconciliation falsely marked it 'completed',
hiding the failure from the orchestrator. Record the error for non-orchestrator
sessions with a running board entry unless a fallback is in progress.

Adds regression tests for the child-error path and the fallback-in-progress guard.
Michael Henke 2 هفته پیش
والد
کامیت
045e7a1e9e
3فایلهای تغییر یافته به همراه105 افزوده شده و 6 حذف شده
  1. 73 0
      src/hooks/task-session-manager/index.test.ts
  2. 21 0
      src/hooks/task-session-manager/index.ts
  3. 11 6
      src/utils/env.test.ts

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

@@ -1717,6 +1717,79 @@ describe('task-session-manager hook', () => {
     expect(job?.resultSummary).toBe('connection refused');
   });
 
+  test('child session.error (non-orchestrator) records failure on board', async () => {
+    const board = new BackgroundJobBoard();
+    // Child subagent sessions are not orchestrators, so shouldManageSession
+    // returns false for them. The error must still land on the board,
+    // otherwise idle reconciliation marks the job completed (false success).
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: () => false,
+    });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'designer',
+      description: 'design ui',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'running' });
+
+    await hook.event({
+      event: {
+        type: 'session.error',
+        properties: {
+          sessionID: 'child-1',
+          error: {
+            name: 'AI_APICallError',
+            message: 'Internal server error',
+          },
+        },
+      },
+    });
+
+    const job = board.get('child-1');
+    expect(job?.state).toBe('error');
+    expect(job?.resultSummary).toBe('Internal server error');
+  });
+
+  test('child session.error during fallback is not recorded on board', async () => {
+    const board = new BackgroundJobBoard();
+    // isFallbackInProgress is currently always-false for real children
+    // (they have no fallback chain), so this guard path is unreachable in
+    // production today. The test pins the defensive behavior for the day
+    // children gain a fallback chain.
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: () => false,
+      isFallbackInProgress: () => true,
+    });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'designer',
+      description: 'design ui',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'running' });
+
+    await hook.event({
+      event: {
+        type: 'session.error',
+        properties: {
+          sessionID: 'child-1',
+          error: {
+            name: 'AI_APICallError',
+            message: 'Internal server error',
+          },
+        },
+      },
+    });
+
+    const job = board.get('child-1');
+    expect(job?.state).toBe('running');
+  });
+
   test('completed reconciled job appears reusable and resumes via task', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });

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

@@ -1166,6 +1166,27 @@ export function createTaskSessionManagerHook(
               });
             }
           }
+        } else if (sessionId) {
+          // Child subagent sessions are not orchestrators, so the block
+          // above never runs for them. Without this, a failed background
+          // subagent leaves its job in `running` and the idle-reconciliation
+          // path (which has no shouldManageSession guard) marks it
+          // `completed` — a false success. A child with no fallback chain has
+          // nothing to retry into, so surface the failure on the board.
+          const props = input.event.properties as
+            | { error?: unknown }
+            | undefined;
+          if (options.isFallbackInProgress?.(sessionId)) return;
+          const job = backgroundJobBoard.get(sessionId);
+          if (job && job.state === 'running') {
+            backgroundJobBoard.updateStatus({
+              taskID: sessionId,
+              state: 'error',
+              resultSummary:
+                (props?.error as { message?: string } | undefined)?.message ??
+                'Session error',
+            });
+          }
         }
 
         return;

+ 11 - 6
src/utils/env.test.ts

@@ -10,12 +10,17 @@ describe('isTruthyEnvValue', () => {
     expect(isTruthyEnvValue(value)).toBe(true);
   });
 
-  test.each([undefined, '', '0', 'false', 'no', 'off', 'anything'])(
-    '%p is not truthy',
-    (value) => {
-      expect(isTruthyEnvValue(value)).toBe(false);
-    },
-  );
+  test.each([
+    undefined,
+    '',
+    '0',
+    'false',
+    'no',
+    'off',
+    'anything',
+  ])('%p is not truthy', (value) => {
+    expect(isTruthyEnvValue(value)).toBe(false);
+  });
 });
 
 describe('isPluginDisabledByEnv', () => {