Explorar o código

fix: reuse foreground task sessions

Alvin Unreal hai 2 meses
pai
achega
c3463ad097

+ 3 - 0
src/agents/orchestrator.ts

@@ -230,6 +230,9 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - When too much unrelated, and really needed, start a fresh session with the specialist
 - If multiple remembered sessions fit, prefer the most recently used matching session.
 - Prefer re-uses over creating new sessions all the time
+- When reusing a specialist session, you MUST pass the existing session or alias in the task tool's \`task_id\` argument. Saying "reuse" in prose is not enough.
+- If the Background Job Board lists \`fix-1 / ses_abc / fixer\`, call task with \`subagent_type: "fixer"\` and \`task_id: "fix-1"\` or \`task_id: "ses_abc"\`.
+- Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session.
 
 ### Validation routing
 - Validation is a workflow stage owned by the Orchestrator, not a separate specialist

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

@@ -1370,6 +1370,78 @@ describe('task-session-manager hook', () => {
     expect(messages.messages[0].parts[0].text).toBe('continue');
   });
 
+  test('completed foreground XML task output becomes reusable after reconciliation', async () => {
+    const { hook } = createHook();
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'fixer', description: 'reuse probe' } },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        output: [
+          '<task id="ses_child" state="completed">',
+          '<task_result>',
+          'done',
+          '</task_result>',
+          '</task>',
+        ].join('\n'),
+      },
+    );
+
+    const unreconciled = createMessages('parent-1', 'continue');
+    await hook['experimental.chat.messages.transform']({}, unreconciled);
+    expect(unreconciled.messages[0].parts[0].text).toContain(
+      'fix-1 / ses_child / fixer / completed, unreconciled',
+    );
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+
+    const reusable = createMessages('parent-1', 'reuse');
+    await hook['experimental.chat.messages.transform']({}, reusable);
+    expect(reusable.messages[0].parts[0].text).toContain(
+      'fix-1 / ses_child / fixer / completed, reconciled',
+    );
+
+    const resume = { args: { subagent_type: 'fixer', task_id: 'fix-1' } };
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'resume-1' },
+      resume,
+    );
+    expect(resume.args.task_id).toBe('ses_child');
+  });
+
+  test('preserves explicit raw session ids when reusable board misses', async () => {
+    const { hook } = createHook();
+    const resume = {
+      args: { subagent_type: 'fixer', task_id: 'ses_existing' },
+    };
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'resume-1' },
+      resume,
+    );
+
+    expect(resume.args.task_id).toBe('ses_existing');
+  });
+
+  test('still drops unknown reusable aliases', async () => {
+    const { hook } = createHook();
+    const resume = { args: { subagent_type: 'fixer', task_id: 'fix-99' } };
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'resume-1' },
+      resume,
+    );
+
+    expect(resume.args.task_id).toBeUndefined();
+  });
+
   test('reads before and after launch attach with unique-line counts and caps', async () => {
     const { hook } = createHook({
       readContextMinLines: 5,

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

@@ -69,6 +69,7 @@ const BACKGROUND_JOB_BOARD_SENTINEL = 'SENTINEL: background-job-board-v2';
 const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
 const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
 const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
+const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
 
 /**
  * Simple deterministic string hash for stable occurrence IDs.
@@ -624,6 +625,11 @@ export function createTaskSessionManagerHook(
       );
 
       if (!remembered) {
+        if (RAW_SESSION_ID_PATTERN.test(requested)) {
+          pendingCall.resumedTaskId = requested;
+          rememberPendingCall(pendingCall);
+          return;
+        }
         delete args.task_id;
         return;
       }
@@ -691,6 +697,43 @@ export function createTaskSessionManagerHook(
         return;
       }
 
+      const status = parseTaskStatusOutput(output.output);
+      if (status) {
+        const existing = backgroundJobBoard.get(status.taskID);
+        const record =
+          existing ??
+          backgroundJobBoard.registerLaunch({
+            taskID: status.taskID,
+            parentSessionID: pending.parentSessionId,
+            agent: pending.agentType,
+            description: pending.label,
+            objective: pending.label,
+          });
+        const updated = backgroundJobBoard.updateStatus({
+          taskID: status.taskID,
+          state: status.state,
+          timedOut: status.timedOut,
+          resultSummary: status.result,
+        });
+        log('[task-session-manager] foreground task status registered', {
+          taskID: status.taskID,
+          alias: updated?.alias ?? record.alias,
+          parentSessionID: pending.parentSessionId,
+          agent: pending.agentType,
+          state: updated?.state ?? record.state,
+        });
+        if (pending.resumedTaskId && pending.resumedTaskId !== status.taskID) {
+          backgroundJobBoard.drop(pending.resumedTaskId);
+        }
+        pendingManagedTaskIds.delete(status.taskID);
+        const contextFiles = contextFilesForPrompt(
+          contextByTask.get(status.taskID),
+        );
+        backgroundJobBoard.addContext(status.taskID, contextFiles);
+        pruneContext();
+        return;
+      }
+
       const taskId = parseTaskIdFromTaskOutput(output.output);
       if (!taskId) {
         if (

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

@@ -19,6 +19,18 @@ describe('parseTaskIdFromTaskOutput', () => {
     expect(parseTaskIdFromTaskOutput(output)).toBe('session-abc-123');
   });
 
+  test('parses task id from XML task output', () => {
+    const output = [
+      '<task id="ses_123" state="completed">',
+      '<task_result>',
+      'done',
+      '</task_result>',
+      '</task>',
+    ].join('\n');
+
+    expect(parseTaskIdFromTaskOutput(output)).toBe('ses_123');
+  });
+
   test('returns undefined when task_id is absent', () => {
     const output = ['<task_result>', 'no task id here', '</task_result>'].join(
       '\n',
@@ -46,6 +58,22 @@ describe('parseTaskLaunchOutput', () => {
     });
   });
 
+  test('parses XML background task launch output', () => {
+    const output = [
+      '<task id="ses_123" state="running">',
+      '<task_result>',
+      'Background task started.',
+      '</task_result>',
+      '</task>',
+    ].join('\n');
+
+    expect(parseTaskLaunchOutput(output)).toEqual({
+      taskID: 'ses_123',
+      state: 'running',
+      result: 'Background task started.',
+    });
+  });
+
   test('ignores blocking task output without running state', () => {
     const output = [
       'task_id: ses_123 (for resuming to continue this task if needed)',
@@ -90,6 +118,23 @@ describe('parseTaskStatusOutput', () => {
     });
   });
 
+  test('parses XML completed status output with task result', () => {
+    const output = [
+      '<task id="ses_123" state="completed">',
+      '<task_result>',
+      'done',
+      '</task_result>',
+      '</task>',
+    ].join('\n');
+
+    expect(parseTaskStatusOutput(output)).toEqual({
+      taskID: 'ses_123',
+      state: 'completed',
+      timedOut: false,
+      result: 'done',
+    });
+  });
+
   test('parses error status output with task_error', () => {
     const output = [
       'task_id: ses_123',

+ 9 - 0
src/utils/task.ts

@@ -30,6 +30,9 @@ const TRANSIENT_PROCESS_ERROR_TEXT = new Set([
 ]);
 
 export function parseTaskIdFromTaskOutput(output: string): string | undefined {
+  const xmlMatch = /<task\s+[^>]*\bid=["']([^"']+)["'][^>]*>/i.exec(output);
+  if (xmlMatch) return xmlMatch[1];
+
   const lines = output.split(/\r?\n/);
 
   for (const line of lines) {
@@ -94,6 +97,12 @@ export function classifyTaskStatusOutput(
 export function parseTaskStateFromOutput(
   output: string,
 ): TaskOutputState | undefined {
+  const xmlMatch =
+    /<task\s+[^>]*\bstate=["'](running|completed|error|cancelled)["'][^>]*>/i.exec(
+      output,
+    );
+  if (xmlMatch) return xmlMatch[1].toLowerCase() as TaskOutputState;
+
   for (const line of getTaskHeader(output).split(/\r?\n/)) {
     const match = /^state:\s*(running|completed|error|cancelled)\s*$/i.exec(
       line.trim(),