Sfoglia il codice sorgente

Normalize late cancelled task errors

Alvin Unreal 2 mesi fa
parent
commit
5c73bb9dd2

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

@@ -891,6 +891,98 @@ describe('task-session-manager hook', () => {
     });
   });
 
+  test('normalizes late injected failure for an explicitly cancelled task', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'cancelled review',
+    });
+    board.markCancelled('child-1', 'user requested');
+    board.markReconciled('child-1');
+
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              synthetic: true,
+              text: [
+                'Background task failed: cancelled review',
+                'task_id: child-1',
+                'state: error',
+                '',
+                '<task_error>',
+                'No user message found in stream. This should never happen.',
+                '</task_error>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(messages.messages[0].parts[0].text).toContain('state: cancelled');
+    expect(messages.messages[0].parts[0].text).toContain(
+      'cancelled: user requested',
+    );
+    expect(messages.messages[0].parts[0].text).not.toContain(
+      'No user message found',
+    );
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalState: 'cancelled',
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('normalizes late task_status error output for an explicitly cancelled task', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'cancelled review',
+    });
+    board.markCancelled('child-1', 'user requested');
+    board.markReconciled('child-1');
+
+    const output = {
+      output: [
+        'task_id: child-1',
+        'state: error',
+        '',
+        '<task_error>',
+        'No user message found in stream. This should never happen.',
+        '</task_error>',
+      ].join('\n'),
+      metadata: { state: 'error' },
+    };
+
+    await hook['tool.execute.after'](
+      { tool: 'task_status', sessionID: 'parent-1', callID: 'call-2' },
+      output,
+    );
+
+    expect(output.output).toContain('state: cancelled');
+    expect(output.output).toContain('cancelled: user requested');
+    expect(output.output).not.toContain('No user message found');
+    expect(output.metadata).toMatchObject({ state: 'cancelled' });
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalState: 'cancelled',
+    });
+  });
+
   test('marks terminal jobs reconciled after injected prompt reaches idle', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });

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

@@ -261,6 +261,18 @@ export function createTaskSessionManagerHook(
       hasResult: Boolean(status.result),
     });
 
+    const existing = backgroundJobBoard.get(status.taskID);
+    if (isLateCancelledTaskError(existing, status.state)) {
+      log('[task-session-manager] suppressed late cancelled task error', {
+        taskID: status.taskID,
+        alias: existing?.alias,
+        state: existing?.state,
+        terminalState: existing?.terminalState,
+        result: status.result,
+      });
+      return existing;
+    }
+
     const updated = backgroundJobBoard.updateStatus({
       taskID: status.taskID,
       state: status.state,
@@ -394,14 +406,31 @@ export function createTaskSessionManagerHook(
     const status = parseTaskStatusOutput(part.text);
     if (!status) return undefined;
 
+    const occurrenceId = createOccurrenceId(part, message, partIndex);
+
+    const existing = backgroundJobBoard.get(status.taskID);
+    if (isFailed && isLateCancelledTaskError(existing, status.state)) {
+      part.text = formatCancelledTaskStatusOutput(
+        status.taskID,
+        existing?.resultSummary,
+      );
+      log('[task-session-manager] normalized late cancelled injected failure', {
+        taskID: status.taskID,
+        alias: existing?.alias,
+        state: existing?.state,
+        terminalState: existing?.terminalState,
+        result: status.result,
+      });
+      rememberProcessedInjectedCompletion(occurrenceId);
+      return existing;
+    }
+
     // Enforce prefix/state consistency: completed prefix only accepts completed state
     // failed prefix only accepts error state; ignore running/cancelled in auto-injected path
     if (isCompleted && status.state !== 'completed') return undefined;
     if (isFailed && status.state !== 'error') return undefined;
 
     // Dedupe by synthetic message occurrence using stable occurrence ID
-    const occurrenceId = createOccurrenceId(part, message, partIndex);
-
     if (processedInjectedCompletions.has(occurrenceId)) return undefined;
 
     const updated = updateBackgroundJobFromOutput(part.text);
@@ -624,6 +653,7 @@ export function createTaskSessionManagerHook(
         if (!input.sessionID || !options.shouldManageSession(input.sessionID)) {
           return;
         }
+        normalizeLateCancelledToolStatus(output);
         if (await handleTransientTaskStatusOutput(output)) {
           return;
         }
@@ -874,4 +904,52 @@ export function createTaskSessionManagerHook(
       }
     },
   };
+
+  function normalizeLateCancelledToolStatus(output: {
+    output: unknown;
+    metadata?: unknown;
+  }): void {
+    if (typeof output.output !== 'string') return;
+    const status = parseTaskStatusOutput(output.output);
+    if (!status) return;
+    const existing = backgroundJobBoard.get(status.taskID);
+    if (!isLateCancelledTaskError(existing, status.state)) return;
+    log('[task-session-manager] normalized late cancelled task_status output', {
+      taskID: status.taskID,
+      alias: existing?.alias,
+      state: existing?.state,
+      terminalState: existing?.terminalState,
+      result: status.result,
+    });
+    output.output = formatCancelledTaskStatusOutput(
+      status.taskID,
+      existing?.resultSummary,
+    );
+    if (isObjectRecord(output) && isObjectRecord(output.metadata)) {
+      output.metadata.state = 'cancelled';
+    }
+  }
+}
+
+function isLateCancelledTaskError(
+  job: BackgroundJobRecord | undefined,
+  state: string,
+): boolean {
+  if (state !== 'error') return false;
+  if (!job?.cancellationRequested) return false;
+  return job.state === 'cancelled' || job.terminalState === 'cancelled';
+}
+
+function formatCancelledTaskStatusOutput(
+  taskID: string,
+  summary = 'cancelled',
+): string {
+  return [
+    `task_id: ${taskID}`,
+    'state: cancelled',
+    '',
+    '<task_error>',
+    summary,
+    '</task_error>',
+  ].join('\n');
 }