Browse Source

fix: reconcile injected terminal jobs

Injected completion messages updated job status but skipped the per-parent reconciliation set, leaving parents idle with terminal jobs pending.\n\nTrack only the injected task so idle reconciliation clears it without reconciling unseen siblings.\n\nRefs #915
bruce Mead 3 weeks ago
parent
commit
19ac94b933

+ 32 - 0
src/hooks/task-session-manager/board-injection.ts

@@ -213,6 +213,13 @@ export function updateFromInjectedCompletion(
       result: status.result,
     });
     rememberProcessedInjectedCompletion(state, occurrenceId);
+    if (existing?.terminalUnreconciled && existing?.parentSessionID) {
+      rememberInjectedTerminalJob(
+        state,
+        existing.parentSessionID,
+        existing.taskID,
+      );
+    }
     return existing;
   }
 
@@ -228,6 +235,10 @@ export function updateFromInjectedCompletion(
   );
   if (!updated) return undefined;
 
+  if (updated.terminalUnreconciled && updated.parentSessionID) {
+    rememberInjectedTerminalJob(state, updated.parentSessionID, updated.taskID);
+  }
+
   log('[task-session-manager] processed injected background completion', {
     taskID: updated.taskID,
     alias: updated.alias,
@@ -266,6 +277,27 @@ export function isMissingRememberedSessionError(output: string): boolean {
   );
 }
 
+function rememberInjectedTerminalJob(
+  state: InjectionState,
+  parentSessionID: string,
+  taskID: string,
+): void {
+  if (!parentSessionID || !taskID) return;
+
+  const existing =
+    state.terminalJobsInjectedByParent.get(parentSessionID) ??
+    new Set<string>();
+  if (existing.has(taskID)) return;
+
+  existing.add(taskID);
+  state.terminalJobsInjectedByParent.set(parentSessionID, existing);
+
+  log('[task-session-manager] terminal job injected for reconciliation', {
+    parentSessionID,
+    taskID,
+  });
+}
+
 export function rememberInjectedTerminalJobs(
   state: InjectionState,
   parentSessionID: string,

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

@@ -1302,6 +1302,158 @@ describe('task-session-manager hook', () => {
     );
   });
 
+  test('injected completion through message transform (without injectBackgroundJobBoard) remains terminal-unreconciled before parent idle, then reconciles after', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: {
+          subagent_type: 'explorer',
+          description: 'map hooks',
+        },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        output: ['task_id: child-1', 'state: running'].join('\n'),
+      },
+    );
+
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              id: 'part-1',
+              synthetic: true,
+              text: [
+                '<task id="child-1" state="completed">',
+                '<summary>Background task completed: map hooks</summary>',
+                '<task_result>',
+                'found hook flow',
+                '</task_result>',
+                '</task>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    // through transform only, without injectBackgroundJobBoard (avoids broad remember)
+    await hook['experimental.chat.messages.transform']({}, messages as never);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+      resultSummary: 'found hook flow',
+    });
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
+
+    // duplicate occurrence is idempotent (no reprocess, no double remember)
+    await hook['experimental.chat.messages.transform']({}, messages as never);
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+
+    await flushIdleReconcileDelay();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('another terminal-unreconciled sibling remains unreconciled when only first child completion was injected', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    // setup child-1
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: { subagent_type: 'explorer', description: 'first' },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { output: ['task_id: child-1', 'state: running'].join('\n') },
+    );
+
+    // setup sibling child-2 (will be terminal via updateStatus, no injected completion)
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      {
+        args: { subagent_type: 'oracle', description: 'second' },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      { output: ['task_id: child-2', 'state: running'].join('\n') },
+    );
+
+    board.updateStatus({
+      taskID: 'child-2',
+      state: 'completed',
+      resultSummary: 'sibling done',
+    });
+    expect(board.get('child-2')?.terminalUnreconciled).toBe(true);
+
+    // only first gets injected completion (via transform, no board inject)
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              id: 'part-1',
+              synthetic: true,
+              text: [
+                '<task id="child-1" state="completed">',
+                '<summary>Background task completed: first</summary>',
+                '<task_result>done1</task_result>',
+                '</task>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+    await hook['experimental.chat.messages.transform']({}, messages as never);
+
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
+    expect(board.get('child-2')?.terminalUnreconciled).toBe(true);
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    await flushIdleReconcileDelay();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    // sibling not remembered via narrow path, stays unreconciled
+    expect(board.get('child-2')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+    });
+  });
+
   test('ignores non-synthetic user text that resembles task status', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });