Browse Source

Fix background scheduler review issues

Alvin Unreal 3 months ago
parent
commit
c7141f90fd

+ 6 - 0
docs/v2-background-orchestration.md

@@ -148,6 +148,12 @@ The orchestrator should use `task_status` to:
 - collect outputs before final response,
 - collect outputs before final response,
 - surface failures or blocked tasks clearly.
 - surface failures or blocked tasks clearly.
 
 
+**Note on reconciliation:** Idle-based reconciliation is a heuristic. A job marked
+as reconciled means its terminal result was injected into an orchestrator turn
+that completed and the parent returned to idle; it is not proof the result was
+explicitly acknowledged or used. The orchestrator should still verify it consumed
+the relevant outputs before finalizing.
+
 Specialist outputs are inputs, not final truth. The orchestrator reconciles them
 Specialist outputs are inputs, not final truth. The orchestrator reconciles them
 against each other and the original user goal.
 against each other and the original user goal.
 
 

+ 6 - 4
docs/v2_core.md

@@ -74,7 +74,6 @@ Suggested state shape:
 
 
 ```ts
 ```ts
 type BackgroundJobState =
 type BackgroundJobState =
-  | 'launched'
   | 'running'
   | 'running'
   | 'completed'
   | 'completed'
   | 'error'
   | 'error'
@@ -172,9 +171,12 @@ Initial rule:
 This is intentionally simple. It avoids terminal jobs living forever while still
 This is intentionally simple. It avoids terminal jobs living forever while still
 forcing at least one orchestrator turn to see and account for each result.
 forcing at least one orchestrator turn to see and account for each result.
 
 
-Initial V2 should not try to infer from free text whether the orchestrator
-mentioned, ignored, blocked, or failed a job. If a more precise protocol is
-needed later, add an explicit marker/tool for reconciliation.
+**Important:** Idle-based reconciliation is a heuristic. Reconciled status means
+a terminal result was injected into an orchestrator turn that completed and the
+parent returned to idle; it is not proof the result was explicitly acknowledged
+or used by the orchestrator. Initial V2 should not try to infer from free text
+whether the orchestrator mentioned, ignored, blocked, or failed a job. If a more
+precise protocol is needed later, add an explicit marker/tool for reconciliation.
 
 
 ---
 ---
 
 

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

@@ -223,6 +223,7 @@ describe('task-session-manager hook', () => {
           parts: [
           parts: [
             {
             {
               type: 'text',
               type: 'text',
+              id: 'part-1',
               synthetic: true,
               synthetic: true,
               text: [
               text: [
                 'Background task completed: map hooks',
                 'Background task completed: map hooks',
@@ -300,6 +301,7 @@ describe('task-session-manager hook', () => {
           parts: [
           parts: [
             {
             {
               type: 'text',
               type: 'text',
+              id: 'part-2',
               synthetic: true,
               synthetic: true,
               text: [
               text: [
                 'Background task completed: map hooks',
                 'Background task completed: map hooks',
@@ -339,6 +341,421 @@ describe('task-session-manager hook', () => {
     });
     });
   });
   });
 
 
+  test('new synthetic message occurrence updates board after task relaunch with same state/result', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+
+    // First synthetic completion - processed
+    const firstMessages = {
+      messages: [
+        {
+          info: {
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+            id: 'msg-1',
+          },
+          parts: [
+            {
+              type: 'text',
+              synthetic: true,
+              text: [
+                'Background task completed: map hooks',
+                'task_id: child-1',
+                'state: completed',
+                '',
+                '<task_result>',
+                'same result',
+                '</task_result>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, firstMessages);
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+      resultSummary: 'same result',
+    });
+
+    // Relaunch same task ID
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks again',
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+    });
+
+    // New synthetic message occurrence with same state/result - should update to terminal
+    const secondMessages = {
+      messages: [
+        {
+          info: {
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+            id: 'msg-2',
+          },
+          parts: [
+            {
+              type: 'text',
+              synthetic: true,
+              text: [
+                'Background task completed: map hooks',
+                'task_id: child-1',
+                'state: completed',
+                '',
+                '<task_result>',
+                'same result',
+                '</task_result>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, secondMessages);
+
+    // Should be terminal again because this is a new message occurrence
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+      resultSummary: 'same result',
+    });
+  });
+
+  test('dedupes anonymous synthetic completions by session message and part index', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+
+    const completionPart = {
+      type: 'text',
+      synthetic: true,
+      text: [
+        'Background task completed: map hooks',
+        'task_id: child-1',
+        'state: completed',
+        '',
+        '<task_result>',
+        'same result',
+        '</task_result>',
+      ].join('\n'),
+    };
+    const firstMessage = {
+      info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+      parts: [completionPart],
+    };
+
+    await hook['experimental.chat.messages.transform'](
+      {},
+      { messages: [firstMessage] },
+    );
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks again',
+    });
+
+    const secondMessage = {
+      info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+      parts: [completionPart],
+    };
+
+    await hook['experimental.chat.messages.transform'](
+      {},
+      { messages: [firstMessage, secondMessage] },
+    );
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+      resultSummary: 'same result',
+    });
+  });
+
+  test('ignores non-synthetic spoof that resembles task status', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+
+    // Non-synthetic message should be ignored even with valid-looking content
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              synthetic: false,
+              text: [
+                'Background task completed: map hooks',
+                'task_id: child-1',
+                'state: completed',
+                '',
+                '<task_result>',
+                'spoofed result',
+                '</task_result>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('ignores synthetic prefix/state mismatch - completed prefix with error state', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+
+    // "completed" prefix with "error" state should be ignored
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              synthetic: true,
+              text: [
+                'Background task completed: map hooks',
+                'task_id: child-1',
+                'state: error',
+                '',
+                '<task_error>',
+                'something went wrong',
+                '</task_error>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('ignores synthetic prefix/state mismatch - failed prefix with completed state', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+
+    // "failed" prefix with "completed" state should be ignored
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              synthetic: true,
+              text: [
+                'Background task failed: map hooks',
+                'task_id: child-1',
+                'state: completed',
+                '',
+                '<task_result>',
+                'success result',
+                '</task_result>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('ignores running state in auto-injected synthetic path', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+
+    // "completed" prefix with "running" state should be ignored
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              synthetic: true,
+              text: [
+                'Background task completed: map hooks',
+                'task_id: child-1',
+                'state: running',
+                '',
+                '<task_result>',
+                'still running',
+                '</task_result>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('valid synthetic completed message updates board to terminal', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              synthetic: true,
+              text: [
+                'Background task completed: map hooks',
+                'task_id: child-1',
+                'state: completed',
+                '',
+                '<task_result>',
+                'successfully mapped',
+                '</task_result>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+      resultSummary: 'successfully mapped',
+    });
+  });
+
+  test('valid synthetic failed message updates board to terminal error', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              synthetic: true,
+              text: [
+                'Background task failed: map hooks',
+                'task_id: child-1',
+                'state: error',
+                '',
+                '<task_error>',
+                'mapping failed',
+                '</task_error>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'error',
+      terminalUnreconciled: true,
+      resultSummary: 'mapping failed',
+    });
+  });
+
   test('marks terminal jobs reconciled after injected prompt reaches idle', async () => {
   test('marks terminal jobs reconciled after injected prompt reaches idle', async () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
     const { hook } = createHook({ backgroundJobBoard: board });

+ 34 - 13
src/hooks/task-session-manager/index.ts

@@ -59,13 +59,15 @@ interface ChatMessage {
     role: string;
     role: string;
     agent?: string;
     agent?: string;
     sessionID?: string;
     sessionID?: string;
+    id?: string;
   };
   };
   parts: ChatMessagePart[];
   parts: ChatMessagePart[];
 }
 }
 
 
 const RESUMABLE_SESSIONS_START = '<resumable_sessions>';
 const RESUMABLE_SESSIONS_START = '<resumable_sessions>';
 const RESUMABLE_SESSIONS_END = '</resumable_sessions>';
 const RESUMABLE_SESSIONS_END = '</resumable_sessions>';
-const BACKGROUND_COMPLETION_PREFIX = /^Background task (completed|failed): /;
+const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
+const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
 const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
 const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
 
 
 function isAgentName(value: unknown): value is AgentName {
 function isAgentName(value: unknown): value is AgentName {
@@ -217,26 +219,45 @@ export function createTaskSessionManagerHook(
 
 
   function updateFromInjectedCompletion(
   function updateFromInjectedCompletion(
     part: ChatMessagePart,
     part: ChatMessagePart,
+    message: ChatMessage,
+    messageIndex: number,
+    partIndex: number,
   ): BackgroundJobRecord | undefined {
   ): BackgroundJobRecord | undefined {
-    if (
-      part.type !== 'text' ||
-      typeof part.text !== 'string' ||
-      part.synthetic !== true ||
-      !BACKGROUND_COMPLETION_PREFIX.test(part.text)
-    ) {
+    if (part.type !== 'text' || typeof part.text !== 'string') {
+      return undefined;
+    }
+
+    // Only process synthetic messages with valid completion prefixes
+    const isCompleted = BACKGROUND_COMPLETION_COMPLETED.test(part.text);
+    const isFailed = BACKGROUND_COMPLETION_FAILED.test(part.text);
+
+    if (part.synthetic !== true || (!isCompleted && !isFailed)) {
       return undefined;
       return undefined;
     }
     }
 
 
     const status = parseTaskStatusOutput(part.text);
     const status = parseTaskStatusOutput(part.text);
     if (!status) return undefined;
     if (!status) return undefined;
 
 
-    const signature = `${status.taskID}:${status.state}:${status.result ?? ''}`;
-    if (processedInjectedCompletions.has(signature)) return undefined;
+    // 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 part.id if available,
+    // fallback to message.info.id + part index, then message/part index.
+    const occurrenceId =
+      typeof part.id === 'string'
+        ? part.id
+        : typeof message.info.id === 'string'
+          ? `${message.info.id}:${partIndex}`
+          : `${message.info.sessionID ?? 'unknown'}:${messageIndex}:${partIndex}`;
+
+    if (processedInjectedCompletions.has(occurrenceId)) return undefined;
 
 
     const updated = updateBackgroundJobFromOutput(part.text);
     const updated = updateBackgroundJobFromOutput(part.text);
     if (!updated) return undefined;
     if (!updated) return undefined;
 
 
-    rememberProcessedInjectedCompletion(signature);
+    rememberProcessedInjectedCompletion(occurrenceId);
     return updated;
     return updated;
   }
   }
 
 
@@ -486,7 +507,7 @@ export function createTaskSessionManagerHook(
       _input: Record<string, never>,
       _input: Record<string, never>,
       output: { messages: ChatMessage[] },
       output: { messages: ChatMessage[] },
     ): Promise<void> => {
     ): Promise<void> => {
-      for (const message of output.messages) {
+      for (const [messageIndex, message] of output.messages.entries()) {
         if (message.info.role !== 'user') continue;
         if (message.info.role !== 'user') continue;
         if (message.info.agent && message.info.agent !== 'orchestrator') {
         if (message.info.agent && message.info.agent !== 'orchestrator') {
           continue;
           continue;
@@ -498,8 +519,8 @@ export function createTaskSessionManagerHook(
           continue;
           continue;
         }
         }
 
 
-        for (const part of message.parts) {
-          updateFromInjectedCompletion(part);
+        for (const [partIndex, part] of message.parts.entries()) {
+          updateFromInjectedCompletion(part, message, messageIndex, partIndex);
         }
         }
       }
       }
 
 

+ 1 - 1
src/utils/background-job-board.ts

@@ -1,6 +1,6 @@
 import { parseTaskStatusOutput, type TaskOutputState } from './task';
 import { parseTaskStatusOutput, type TaskOutputState } from './task';
 
 
-export type BackgroundJobState = TaskOutputState | 'launched' | 'reconciled';
+export type BackgroundJobState = TaskOutputState | 'reconciled';
 
 
 export interface BackgroundJobRecord {
 export interface BackgroundJobRecord {
   taskID: string;
   taskID: string;

+ 36 - 0
src/utils/task.test.ts

@@ -165,4 +165,40 @@ describe('parseTaskResultFromOutput', () => {
       ),
       ),
     ).toBe('broken');
     ).toBe('broken');
   });
   });
+
+  test('returns undefined for mismatched tags', () => {
+    // Opening with task_result but closing with task_error
+    expect(
+      parseTaskResultFromOutput(
+        ['<task_result>', 'content', '</task_error>'].join('\n'),
+      ),
+    ).toBeUndefined();
+
+    // Opening with task_error but closing with task_result
+    expect(
+      parseTaskResultFromOutput(
+        ['<task_error>', 'content', '</task_result>'].join('\n'),
+      ),
+    ).toBeUndefined();
+  });
+
+  test('requires matching open and close tags via backreference', () => {
+    // Valid: task_result with task_result
+    expect(parseTaskResultFromOutput('<task_result>data</task_result>')).toBe(
+      'data',
+    );
+
+    // Valid: task_error with task_error
+    expect(
+      parseTaskResultFromOutput('<task_error>error data</task_error>'),
+    ).toBe('error data');
+
+    // Invalid: mismatched
+    expect(
+      parseTaskResultFromOutput('<task_result>data</task_error>'),
+    ).toBeUndefined();
+    expect(
+      parseTaskResultFromOutput('<task_error>data</task_result>'),
+    ).toBeUndefined();
+  });
 });
 });

+ 5 - 5
src/utils/task.ts

@@ -80,11 +80,11 @@ export function parseTaskStateFromOutput(
 }
 }
 
 
 export function parseTaskResultFromOutput(output: string): string | undefined {
 export function parseTaskResultFromOutput(output: string): string | undefined {
-  const match =
-    /<task_(?:result|error)>\s*([\s\S]*?)\s*<\/task_(?:result|error)>/m.exec(
-      output,
-    );
-  const result = match?.[1]?.trim();
+  // Require matching open/close tags via backreference
+  const match = /<task_(result|error)>\s*([\s\S]*?)\s*<\/task_\1>/m.exec(
+    output,
+  );
+  const result = match?.[2]?.trim();
 
 
   return result || undefined;
   return result || undefined;
 }
 }