Просмотр исходного кода

fix(task-session-manager): title-matched early registration and placeholder safety net

session.created early registration now passes the child title into the
peek, no longer fences an unrelated pending when the child is already
registered (the fence caused the owning call's output to be dropped),
and registers a placeholder 'unattributed <agent> task' record for
children it cannot attribute, so task_status/task_result always resolve
them; the matching tool.execute.after corrects the description.
GoldJohnKing 5 дней назад
Родитель
Сommit
cbd2c438e0

+ 155 - 0
src/hooks/task-session-manager/event-router-pairing.test.ts

@@ -0,0 +1,155 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from '../../utils/background-job-board';
+import { handleEvent } from './event-router';
+import { createPendingCallTracker } from './pending-call-tracker';
+
+const PARENT = 'parent-1';
+
+function createDeps(board: BackgroundJobBoard) {
+  return {
+    inputWaits: {
+      trackInputWait: mock(() => {}),
+      clearInputWaits: mock(() => {}),
+      waitsByParent: new Map<string, Set<string | symbol>>(),
+    },
+    idleSessionTokens: {
+      clearSession: mock(() => {}),
+      invalidate: mock(() => {}),
+      disposeLocalState: mock(() => {}),
+      sessionTokens: new Map<string, symbol>(),
+    },
+    options: {
+      shouldManageSession: () => true,
+      now: () => 1_000,
+    },
+    idleReconciler: {
+      scheduleIdleReconciliation: mock(() => {}),
+      scheduleChildIdleReconciliation: mock(() => {}),
+      scheduleErrorTerminalize: mock(() => {}),
+      clearIdleTimers: mock(() => {}),
+      clearAllTimers: mock(() => []),
+    },
+    deferredInlineErrors: new Set<string>(),
+    backgroundJobBoard: board,
+    pendingCallTracker: createPendingCallTracker(),
+    taskContextTracker: {
+      pendingManagedTaskIds: new Set<string>(),
+      clearSession: mock(() => {}),
+      prune: mock(() => {}),
+    },
+    terminalJobsInjectedByParent: new Map(),
+    pendingInjectedTerminalJobsByParent: new Map(),
+    retainedBoardSnapshots: new Map(),
+  };
+}
+
+function route(
+  deps: ReturnType<typeof createDeps>,
+  info: Record<string, unknown>,
+): Promise<void> {
+  return handleEvent(
+    { event: { type: 'session.created', properties: { info } } },
+    deps as never,
+  );
+}
+
+function addPending(
+  deps: ReturnType<typeof createDeps>,
+  callId: string,
+  label: string,
+): void {
+  deps.pendingCallTracker.add({
+    callId,
+    parentSessionId: PARENT,
+    agentType: 'oracle',
+    label,
+    background: true,
+    lifecycleEpoch: 0,
+  });
+}
+
+describe('session.created pairing', () => {
+  test('title match claims the right pending among same-agent parallel calls', async () => {
+    const board = new BackgroundJobBoard();
+    const deps = createDeps(board);
+    addPending(deps, 'a', 'L1');
+    addPending(deps, 'b', 'L2');
+
+    await route(deps, {
+      id: 'child-2',
+      parentID: PARENT,
+      agent: 'oracle',
+      title: 'L2',
+    });
+
+    expect(board.get('child-2')?.description).toBe('L2');
+    expect(deps.pendingCallTracker.take('b')?.earlyRegisteredTaskID).toBe(
+      'child-2',
+    );
+  });
+
+  test('already-registered child does not fence an unrelated pending', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: PARENT,
+      agent: 'oracle',
+      description: 'L1',
+      background: false,
+    });
+    const deps = createDeps(board);
+    addPending(deps, 'b', 'L2');
+
+    await route(deps, {
+      id: 'child-1',
+      parentID: PARENT,
+      agent: 'oracle',
+      title: 'L1',
+    });
+
+    expect(
+      deps.pendingCallTracker.take('b')?.earlyRegistrationRejected,
+    ).toBeUndefined();
+  });
+
+  test('ambiguous same-agent child gets a placeholder registration', async () => {
+    const board = new BackgroundJobBoard();
+    const deps = createDeps(board);
+    addPending(deps, 'a', 'L1');
+    addPending(deps, 'b', 'L2');
+
+    await route(deps, { id: 'child-9', parentID: PARENT, agent: 'oracle' });
+
+    const record = board.get('child-9');
+    expect(record?.description).toBe('unattributed oracle task');
+    expect(record?.state).toBe('running');
+    expect(record?.background).toBe(false);
+  });
+
+  test('child with no pendings at all gets a placeholder registration', async () => {
+    const board = new BackgroundJobBoard();
+    const deps = createDeps(board);
+
+    await route(deps, { id: 'child-9', parentID: PARENT, agent: 'fixer' });
+
+    expect(board.get('child-9')?.description).toBe('unattributed fixer task');
+  });
+
+  test('title that matches no pending yields placeholder, pending untouched', async () => {
+    const board = new BackgroundJobBoard();
+    const deps = createDeps(board);
+    addPending(deps, 'a', 'L1');
+
+    await route(deps, {
+      id: 'child-9',
+      parentID: PARENT,
+      agent: 'oracle',
+      title: 'L9',
+    });
+
+    expect(board.get('child-9')?.description).toBe('unattributed oracle task');
+    expect(
+      deps.pendingCallTracker.take('a')?.earlyRegisteredTaskID,
+    ).toBeUndefined();
+  });
+});

+ 46 - 2
src/hooks/task-session-manager/event-router.ts

@@ -336,12 +336,17 @@ export async function handleEvent(
       const pending = deps.pendingCallTracker.peekByParentAndAgent(
         info.parentID,
         info.agent,
+        typeof info.title === 'string' ? info.title : undefined,
       );
       if (pending && !pending.resumedTaskId && !pending.earlyRegisteredTaskID) {
         if (deps.backgroundJobBoard.get(info.id)) {
-          pending.earlyRegistrationRejected = true;
+          // The child is already registered — its own tool.execute.after
+          // won the race. Fencing the peeked pending here punished an
+          // unrelated call and caused its later output to be dropped
+          // (incident 2026-09-12); the existing board record already
+          // prevents double registration.
           log(
-            '[task-session-manager] refused early registration for an existing task ID',
+            '[task-session-manager] skipped early registration for an already-registered task ID',
             { taskID: info.id, parentSessionID: info.parentID },
           );
         } else {
@@ -387,6 +392,45 @@ export async function handleEvent(
           }
         }
       }
+
+      if (!pending && !deps.backgroundJobBoard.get(info.id)) {
+        // No pending call can be attributed to this child (ambiguous
+        // parallel launches, or the owning pending was consumed).
+        // Register a placeholder so task_status/task_result always
+        // resolve it; the matching tool.execute.after corrects the
+        // description via registerLaunch's existing-record update path
+        // when it fires.
+        const agent =
+          typeof info.agent === 'string' && info.agent ? info.agent : 'unknown';
+        try {
+          const record = deps.backgroundJobBoard.registerLaunch({
+            taskID: info.id,
+            parentSessionID: info.parentID,
+            agent,
+            description: `unattributed ${agent} task`,
+            objective: `unattributed ${agent} task`,
+            background: false,
+          });
+          log(
+            '[task-session-manager] placeholder board registration for unattributed child session',
+            {
+              taskID: record.taskID,
+              alias: record.alias,
+              parentSessionID: info.parentID,
+              agent,
+            },
+          );
+        } catch (error) {
+          log(
+            '[task-session-manager] refused placeholder registration for child session',
+            {
+              taskID: info.id,
+              parentSessionID: info.parentID,
+              error: error instanceof Error ? error.message : String(error),
+            },
+          );
+        }
+      }
     }
     return;
   }