Browse Source

fix: record non-retryable session errors on job board (#478)

session.error handler only cleared timers but never updated the job
board — jobs stayed 'running' and session.idle later flipped them to
'completed' with a generic message. Orchestrator saw a false success.

Now non-retryable errors set state: 'error' on the board. The existing
idle guard (state === 'running') naturally skips error-state jobs.
Michael Henke 3 weeks ago
parent
commit
ed3cb618e6

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

@@ -1494,6 +1494,69 @@ describe('task-session-manager hook', () => {
     });
     });
   });
   });
 
 
+  test('non-retryable session.error marks running job as error on board', 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: 'running' });
+
+    await hook.event({
+      event: {
+        type: 'session.error',
+        properties: {
+          sessionID: 'child-1',
+          error: {
+            name: 'UnknownError',
+            message: 'LLM proxy connection refused',
+          },
+        },
+      },
+    });
+
+    const job = board.get('child-1');
+    expect(job?.state).toBe('error');
+    expect(job?.resultSummary).toBe('LLM proxy connection refused');
+  });
+
+  test('session.idle does not overwrite error state with completed', 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: 'error',
+      resultSummary: 'connection refused',
+    });
+
+    const messages = createMessages('parent-1', 'continue');
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    await hook.event({
+      event: {
+        type: 'session.idle',
+        properties: {
+          info: { id: 'child-1', parentID: 'parent-1' },
+        },
+      },
+    });
+
+    const job = board.get('child-1');
+    expect(job?.state).toBe('error');
+    expect(job?.resultSummary).toBe('connection refused');
+  });
+
   test('completed reconciled job appears reusable and resumes via task', async () => {
   test('completed reconciled job appears reusable and resumes via task', async () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
     const { hook } = createHook({ backgroundJobBoard: board });

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

@@ -1048,6 +1048,18 @@ export function createTaskSessionManagerHook(
             | undefined;
             | undefined;
           if (!props?.error || !isFailoverError(props.error)) {
           if (!props?.error || !isFailoverError(props.error)) {
             terminalJobsInjectedByParent.delete(sessionId);
             terminalJobsInjectedByParent.delete(sessionId);
+            // Record non-retryable errors on the job board so the
+            // orchestrator sees the failure instead of a false completion.
+            const job = backgroundJobBoard.get(sessionId);
+            if (job) {
+              backgroundJobBoard.updateStatus({
+                taskID: sessionId,
+                state: 'error',
+                resultSummary:
+                  (props?.error as { message?: string } | undefined)?.message ??
+                  'Session error',
+              });
+            }
           }
           }
         }
         }