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

Merge pull request #1162 from GoldJohnKing/fix/task-session-parallel-pairing

fix(task-session-manager): correct parallel same-agent task pairing
Alvin 4 дней назад
Родитель
Сommit
db6765d61e

+ 1 - 1
src/hooks/task-session-manager/codemap.md

@@ -63,7 +63,7 @@ All modules depend on `BackgroundJobBoard` from `src/utils/background-job-board.
     - The idle timer remains a backstop for when the model ends its turn without further requests; after reconciling injected terminal results, the opt-in continuation evaluator can run in the same idle cycle under its existing guards
 
 5. **Lifecycle Events (`event`)**
-    - `session.created`: Adds new task IDs to pending managed set
+    - `session.created`: Adds new task IDs to pending managed set. Early board registration claims a pending call only when it can be identified unambiguously: a unique child-session `title` match (the v2 host stamps `title = description` argument, additionally constrained to the child's agent) or a unique agent-type match among unmarked pendings **with no already-consumed same-agent call** — a no-title child arriving after a same-agent call's after-hook consumed its pending is treated as stale and never claims. Ambiguous, stale, or unattributable children get a placeholder `unattributed <agent> task` registration so task_status always resolves them; the owning `tool.execute.after` corrects the description. An already-registered child never fences a pending, and a pending's flags never cause `tool.execute.after` to drop the task ID parsed from its own output. On hosts that do not supply tool call IDs, `tool.execute.after` resolves identity via `takeByTaskID` — matching the task ID parsed from its own output against the pending the early registration claimed for that child — instead of guessing by insertion order among parallel calls; `take()` without a call ID only proceeds when exactly one pending exists for the parent.
     - `session.idle` / `session.status` (idle): Reconciles injected terminal jobs for the parent session (backstop path), then can run the opt-in continuation evaluator in the same idle cycle under its existing guards. Child idle is a stop candidate: the first observation stays provisional, and only a confirmed idle/absent after the 5s grace marks `stopped`
     - `session.status` (busy): Marks sessions as running from live session state and resets pending stop confirmation
     - `session.deleted`: Clears job state, child jobs, and pending call records for the session

+ 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();
+  });
+});

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

@@ -205,6 +205,7 @@ export async function handleEvent(
           id?: string;
           parentID?: string;
           agent?: string;
+          title?: string;
           generation?: number;
           activityAt?: number;
           timestamp?: number;
@@ -274,6 +275,7 @@ export async function handleEvent(
       peekByParentAndAgent(
         parentSessionID: string,
         agentHint?: string,
+        title?: string,
       ): PendingTaskCall | undefined;
       clearSession(sessionID: string): void;
       clearAll?(): void;
@@ -336,12 +338,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 +394,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;
   }

+ 262 - 0
src/hooks/task-session-manager/parallel-same-agent-pairing.test.ts

@@ -0,0 +1,262 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from '../../utils/background-job-board';
+import { createTaskSessionManagerHook } from './index';
+
+const PARENT = 'parent-1';
+
+function createHook(board: BackgroundJobBoard) {
+  return createTaskSessionManagerHook(
+    {
+      client: { session: { status: mock(async () => ({ data: {} })) } },
+      directory: '/tmp',
+      worktree: '/tmp',
+    } as never,
+    {
+      maxSessionsPerAgent: 2,
+      backgroundJobBoard: board,
+      shouldManageSession: () => true,
+    },
+  );
+}
+
+const HOST_LAUNCH = (taskID: string) =>
+  `The subagent is working in the background (sessionID: ${taskID}). You will be notified automatically.`;
+
+function beforeCall(input: { callID: string; description: string }) {
+  return [
+    {
+      tool: 'task',
+      sessionID: PARENT,
+      callID: input.callID,
+    },
+    {
+      args: {
+        subagent_type: 'oracle',
+        description: input.description,
+        prompt: 'do the review',
+        background: true,
+      },
+    },
+  ] as const;
+}
+
+function afterCall(input: { callID: string; taskID: string }) {
+  return [
+    { tool: 'task', sessionID: PARENT, callID: input.callID },
+    { output: HOST_LAUNCH(input.taskID) },
+  ] as const;
+}
+
+function created(input: { child: string; title?: string }) {
+  return {
+    event: {
+      type: 'session.created',
+      properties: {
+        info: {
+          id: input.child,
+          parentID: PARENT,
+          agent: 'oracle',
+          ...(input.title ? { title: input.title } : {}),
+        },
+      },
+    },
+  };
+}
+
+const L_A = 'Review v2 compat layer PRs';
+const L_B = 'Review wake/synthetic PR chain';
+const L_C = 'Review v1 fix PRs';
+
+describe('parallel same-agent pairing (incident 2026-09-12)', () => {
+  test('after-hooks win: all three children registered with correct labels', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+    const sA = 'ses_aaaa1111';
+    const sB = 'ses_bbbb2222';
+    const sC = 'ses_cccc3333';
+
+    // before: insertion order mirrors the incident (B, A, C)
+    await hook['tool.execute.before'](
+      ...beforeCall({ callID: 'call-b', description: L_B }),
+    );
+    await hook['tool.execute.before'](
+      ...beforeCall({ callID: 'call-a', description: L_A }),
+    );
+    await hook['tool.execute.before'](
+      ...beforeCall({ callID: 'call-c', description: L_C }),
+    );
+
+    // .521 after A registers sA
+    await hook['tool.execute.after'](
+      ...afterCall({ callID: 'call-a', taskID: sA }),
+    );
+    // .524 created(sA): board already has it — must not fence pending B
+    await hook.event(created({ child: sA, title: L_A }));
+    // .565 after B registers sB (was dropped by the fence in the incident)
+    await hook['tool.execute.after'](
+      ...afterCall({ callID: 'call-b', taskID: sB }),
+    );
+    // .567 created(sB)
+    await hook.event(created({ child: sB, title: L_B }));
+    // .573 after C registers sC (was dropped by the cross-mark in the incident)
+    await hook['tool.execute.after'](
+      ...afterCall({ callID: 'call-c', taskID: sC }),
+    );
+    // .575 created(sC)
+    await hook.event(created({ child: sC, title: L_C }));
+
+    expect(board.taskIDs()).toEqual(new Set([sA, sB, sC]));
+    expect(board.get(sA)?.description).toBe(L_A);
+    expect(board.get(sB)?.description).toBe(L_B);
+    expect(board.get(sC)?.description).toBe(L_C);
+    const aliases = new Set([sA, sB, sC].map((id) => board.get(id)?.alias));
+    expect(aliases.size).toBe(3);
+  });
+
+  test('created-first ordering: tentative registration is kept correct by the after-hook', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+    const sB = 'ses_bbbb2222';
+
+    await hook['tool.execute.before'](
+      ...beforeCall({ callID: 'call-b', description: L_B }),
+    );
+    await hook['tool.execute.before'](
+      ...beforeCall({ callID: 'call-c', description: L_C }),
+    );
+
+    // created(sB) arrives before after(B): title claims pending B
+    await hook.event(created({ child: sB, title: L_B }));
+    expect(board.get(sB)?.description).toBe(L_B);
+
+    await hook['tool.execute.after'](
+      ...afterCall({ callID: 'call-b', taskID: sB }),
+    );
+    expect(board.get(sB)?.description).toBe(L_B);
+    expect(board.get(sB)?.state).toBe('running');
+  });
+
+  test('no-title hosts: placeholder is corrected by the matching after-hook', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+    const sC = 'ses_cccc3333';
+
+    await hook['tool.execute.before'](
+      ...beforeCall({ callID: 'call-b', description: L_B }),
+    );
+    await hook['tool.execute.before'](
+      ...beforeCall({ callID: 'call-c', description: L_C }),
+    );
+
+    // Ambiguous (two same-agent pendings, no title): placeholder, not a guess
+    await hook.event(created({ child: sC }));
+    expect(board.get(sC)?.description).toBe('unattributed oracle task');
+
+    // The owning call's after-hook corrects the description
+    await hook['tool.execute.after'](
+      ...afterCall({ callID: 'call-c', taskID: sC }),
+    );
+    expect(board.get(sC)?.description).toBe(L_C);
+  });
+
+  test('stale no-title created event cannot cross-mark or misattribute', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+    const sA = 'ses_aaaa1111';
+    const sX = 'ses_xxxx9999';
+    const sB = 'ses_bbbb2222';
+
+    // call A completes before its created event: after-hook consumes its
+    // pending and registers sA
+    await hook['tool.execute.before'](
+      ...beforeCall({ callID: 'call-a', description: L_A }),
+    );
+    await hook['tool.execute.after'](
+      ...afterCall({ callID: 'call-a', taskID: sA }),
+    );
+
+    // call B still pending; a late no-title (v1-style) child whose owning
+    // call was already consumed must not claim pending B — it gets a
+    // placeholder instead of B's label, so no cross-mark can form
+    await hook['tool.execute.before'](
+      ...beforeCall({ callID: 'call-b', description: L_B }),
+    );
+    await hook.event(created({ child: sX }));
+    expect(board.get(sX)?.description).toBe('unattributed oracle task');
+
+    // after(B) parses sB from its own output; pending B was never
+    // cross-marked, so sB registers cleanly with the right label
+    await hook['tool.execute.after'](
+      ...afterCall({ callID: 'call-b', taskID: sB }),
+    );
+
+    expect(board.taskIDs()).toEqual(new Set([sA, sX, sB]));
+    expect(board.get(sB)?.description).toBe(L_B);
+    expect(board.get(sA)?.description).toBe(L_A);
+    // the stale child keeps the honest placeholder label, never B's
+    expect(board.get(sX)?.description).toBe('unattributed oracle task');
+  });
+
+  test('no-callID hosts: swapped after-hooks cannot corrupt descriptions', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+    const sA = 'ses_aaaa1111';
+    const sB = 'ses_bbbb2222';
+    const v1Launch = (taskID: string) =>
+      [
+        `task_id: ${taskID}`,
+        'state: running',
+        '',
+        '<task_result>',
+        'Background task started.',
+        '</task_result>',
+      ].join('\n');
+
+    // two parallel calls WITHOUT callIDs (v1 legacy hosts): the before
+    // hook assigns anonymous pending IDs in insertion order
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: PARENT },
+      {
+        args: {
+          subagent_type: 'oracle',
+          description: L_A,
+          prompt: 'do the review',
+          background: true,
+        },
+      },
+    );
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: PARENT },
+      {
+        args: {
+          subagent_type: 'oracle',
+          description: L_B,
+          prompt: 'do the review',
+          background: true,
+        },
+      },
+    );
+
+    // created-first: titles claim the right pendings
+    await hook.event(created({ child: sA, title: L_A }));
+    await hook.event(created({ child: sB, title: L_B }));
+    expect(board.get(sA)?.description).toBe(L_A);
+    expect(board.get(sB)?.description).toBe(L_B);
+
+    // after-hooks fire in SWAPPED order with no callIDs: the oldest
+    // pending is A's, but this output belongs to call B — the oldest
+    // guess must neither drop sB nor overwrite its correct label
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: PARENT },
+      { output: v1Launch(sB) },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: PARENT },
+      { output: v1Launch(sA) },
+    );
+
+    expect(board.taskIDs()).toEqual(new Set([sA, sB]));
+    expect(board.get(sA)?.description).toBe(L_A);
+    expect(board.get(sB)?.description).toBe(L_B);
+  });
+});

+ 175 - 0
src/hooks/task-session-manager/pending-call-tracker.test.ts

@@ -0,0 +1,175 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  createPendingCallTracker,
+  type PendingTaskCall,
+} from './pending-call-tracker';
+
+function pending(overrides: Partial<PendingTaskCall>): PendingTaskCall {
+  return {
+    callId: 'call-1',
+    parentSessionId: 'parent-1',
+    agentType: 'oracle',
+    label: 'Review thing',
+    background: true,
+    lifecycleEpoch: 0,
+    ...overrides,
+  };
+}
+
+describe('peekByParentAndAgent', () => {
+  test('title match wins among same-agent parallel pendings', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', label: 'L1' }));
+    tracker.add(pending({ callId: 'b', label: 'L2' }));
+    tracker.add(pending({ callId: 'c', label: 'L3' }));
+
+    const hit = tracker.peekByParentAndAgent('parent-1', 'oracle', 'L2');
+
+    expect(hit?.callId).toBe('b');
+  });
+
+  test('title present but no label match refuses instead of falling back to agent', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', label: 'L1' }));
+
+    const hit = tracker.peekByParentAndAgent('parent-1', 'oracle', 'L9');
+
+    expect(hit).toBeUndefined();
+  });
+
+  test('duplicate labels with matching title refuse', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', label: 'L1' }));
+    tracker.add(pending({ callId: 'b', label: 'L1' }));
+
+    const hit = tracker.peekByParentAndAgent('parent-1', 'oracle', 'L1');
+
+    expect(hit).toBeUndefined();
+  });
+
+  test('unique agent match still wins without title (council reviewers)', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', agentType: 'fixer' }));
+    tracker.add(pending({ callId: 'b', agentType: 'oracle' }));
+
+    const hit = tracker.peekByParentAndAgent('parent-1', 'oracle');
+
+    expect(hit?.callId).toBe('b');
+  });
+
+  test('multiple same-agent pendings without title refuse (incident case)', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a' }));
+    tracker.add(pending({ callId: 'b' }));
+    tracker.add(pending({ callId: 'c' }));
+
+    expect(tracker.peekByParentAndAgent('parent-1', 'oracle')).toBeUndefined();
+  });
+
+  test('skips pendings that are early-registered or fenced', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', earlyRegisteredTaskID: 'ses_x' }));
+    tracker.add(pending({ callId: 'b', earlyRegistrationRejected: true }));
+
+    expect(tracker.peekByParentAndAgent('parent-1', 'oracle')).toBeUndefined();
+  });
+
+  test('single unmarked pending without agent hint is returned', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', agentType: 'fixer' }));
+
+    const hit = tracker.peekByParentAndAgent('parent-1');
+
+    expect(hit?.callId).toBe('a');
+  });
+
+  test('title match is constrained by the agent hint', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', agentType: 'fixer', label: 'L1' }));
+    tracker.add(pending({ callId: 'b', agentType: 'oracle', label: 'L2' }));
+
+    // The title matches pending b, but the child's agent is fixer: the
+    // oracle pending must not be claimed across agents.
+    const hit = tracker.peekByParentAndAgent('parent-1', 'fixer', 'L2');
+
+    expect(hit).toBeUndefined();
+  });
+
+  test('stale no-title claim is rejected after a same-agent call was consumed', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a' }));
+    tracker.add(pending({ callId: 'b' }));
+
+    // call a's after-hook consumed its pending; a late no-title
+    // session.created may be a's stale child and must not claim b.
+    tracker.take('a');
+
+    expect(tracker.peekByParentAndAgent('parent-1', 'oracle')).toBeUndefined();
+  });
+
+  test('no-title unique-agent claim still works before any consumption', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', agentType: 'oracle' }));
+    tracker.add(pending({ callId: 'b', agentType: 'fixer' }));
+
+    const hit = tracker.peekByParentAndAgent('parent-1', 'oracle');
+
+    expect(hit?.callId).toBe('a');
+  });
+
+  test('consumed-call staleness guard is scoped by agent', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', agentType: 'oracle' }));
+    tracker.add(pending({ callId: 'b', agentType: 'fixer' }));
+
+    tracker.take('a');
+
+    // The consumed oracle call cannot explain a fixer child.
+    const hit = tracker.peekByParentAndAgent('parent-1', 'fixer');
+
+    expect(hit?.callId).toBe('b');
+  });
+});
+
+describe('take', () => {
+  test('without callID, refuses when multiple pendings match the parent', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a' }));
+    tracker.add(pending({ callId: 'b' }));
+
+    expect(tracker.take(undefined, 'parent-1')).toBeUndefined();
+    // Nothing was consumed by the refused take.
+    expect(tracker.hasConsumedCall('parent-1')).toBe(false);
+  });
+
+  test('without callID, takes the sole pending for the parent', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a' }));
+
+    const taken = tracker.take(undefined, 'parent-1');
+
+    expect(taken?.callId).toBe('a');
+  });
+});
+
+describe('takeByTaskID', () => {
+  test('removes and returns the pending claimed for that task ID', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', earlyRegisteredTaskID: 'ses_x' }));
+    tracker.add(pending({ callId: 'b' }));
+
+    const taken = tracker.takeByTaskID('parent-1', 'ses_x');
+
+    expect(taken?.callId).toBe('a');
+    // The other pending is untouched.
+    expect(tracker.take('b')?.callId).toBe('b');
+  });
+
+  test('returns undefined when no pending is claimed for the task ID', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', earlyRegisteredTaskID: 'ses_x' }));
+
+    expect(tracker.takeByTaskID('parent-1', 'ses_y')).toBeUndefined();
+    expect(tracker.take('a')?.callId).toBe('a');
+  });
+});

+ 165 - 24
src/hooks/task-session-manager/pending-call-tracker.ts

@@ -39,13 +39,30 @@ export interface PendingCallTracker {
     callId?: string,
     parentSessionId?: string,
     ownerBoard?: BackgroundJobStore,
+    options?: { recordConsumed?: boolean },
+  ): PendingTaskCall | undefined;
+  /** Remove and return the pending call whose early registration claimed
+   * `taskID` for this parent — an identity-verified take for hosts that
+   * do not supply tool call IDs. When `ownerBoard` is given and the
+   * early registration was adopted by a different board generation, the
+   * pending is left for that generation (same fence as `take`). */
+  takeByTaskID(
+    parentSessionId: string,
+    taskID: string,
+    ownerBoard?: BackgroundJobStore,
   ): PendingTaskCall | undefined;
   release(call: PendingTaskCall): void;
   peekByParent(parentSessionId: string): PendingTaskCall | undefined;
   peekByParentAndAgent(
     parentSessionId: string,
     agentHint?: string,
+    title?: string,
   ): PendingTaskCall | undefined;
+  /** True when a pending call for this parent (optionally of the given
+   * agent type) was already consumed by its tool.execute.after. A
+   * no-title session.created that arrives after such consumption may be
+   * a stale child of the consumed call, so claims must be refused. */
+  hasConsumedCall(parentSessionId: string, agentType?: string): boolean;
   adoptEarlyRegistrations(
     backgroundJobBoard: BackgroundJobStore,
     backgroundJobSupervisor?: BackgroundJobSupervisor,
@@ -61,6 +78,53 @@ export function createPendingCallTracker(
   const pendingCalls = new Map<string, PendingTaskCall>();
   let anonymousPendingCallId = 0;
 
+  /** Calls already consumed by their tool.execute.after, kept briefly so
+   * late no-title session.created events can be recognized as possibly
+   * stale children of a consumed call instead of claiming an unrelated
+   * pending. */
+  const consumedCalls = new Map<
+    string,
+    { parentSessionId: string; agentType: string }
+  >();
+  const MAX_CONSUMED_CALLS = 200;
+
+  const recordConsumed = (call: PendingTaskCall): void => {
+    consumedCalls.set(call.callId, {
+      parentSessionId: call.parentSessionId,
+      agentType: call.agentType,
+    });
+    while (consumedCalls.size > MAX_CONSUMED_CALLS) {
+      const firstKey = consumedCalls.keys().next().value;
+      if (firstKey === undefined) break;
+      consumedCalls.delete(firstKey);
+    }
+  };
+
+  const hasConsumedFor = (
+    parentSessionId: string,
+    agentType?: string,
+  ): boolean => {
+    for (const consumed of consumedCalls.values()) {
+      if (consumed.parentSessionId !== parentSessionId) continue;
+      if (agentType === undefined || consumed.agentType === agentType) {
+        return true;
+      }
+    }
+    return false;
+  };
+
+  const solePendingIdForParent = (
+    parentSessionId: string,
+  ): string | undefined => {
+    let found: string | undefined;
+    for (const [callId, call] of pendingCalls.entries()) {
+      if (call.parentSessionId !== parentSessionId) continue;
+      if (found !== undefined) return undefined;
+      found = callId;
+    }
+    return found;
+  };
+
   const releaseCallLease = (call: PendingTaskCall): void => {
     if (call.relaunchLease) {
       (call.releaseLease ?? options.releaseLease)?.(call.relaunchLease);
@@ -87,15 +151,18 @@ export function createPendingCallTracker(
       callId?: string,
       parentSessionId?: string,
       ownerBoard?: BackgroundJobStore,
+      takeOptions?: { recordConsumed?: boolean },
     ) {
       if (!callId && parentSessionId) {
-        for (const id of pendingCalls.keys()) {
-          const call = pendingCalls.get(id);
-          if (call && call.parentSessionId === parentSessionId) {
-            callId = id;
-            break;
-          }
-        }
+        // Without a tool call ID a take can only be sound when exactly
+        // one pending exists for the parent (after-hooks fire once per
+        // call, so a sole survivor belongs to this call). With several
+        // candidates, guessing by insertion order would mis-attribute
+        // the label and could overwrite an already-correct record —
+        // refuse and let the caller resolve identity via takeByTaskID.
+        const sole = solePendingIdForParent(parentSessionId);
+        if (!sole) return undefined;
+        callId = sole;
       }
       if (!callId) return undefined;
       const pending = pendingCalls.get(callId);
@@ -107,6 +174,9 @@ export function createPendingCallTracker(
         return undefined;
       }
       pendingCalls.delete(callId);
+      if (pending && takeOptions?.recordConsumed !== false) {
+        recordConsumed(pending);
+      }
       return pending;
     },
 
@@ -129,27 +199,92 @@ export function createPendingCallTracker(
     },
 
     /**
-     * Peek a pending call for a parent, preferring one whose agentType
-     * matches `agentHint`. Used by session.created early registration:
-     * when a parent launches several parallel task tools with different
-     * subagent types (e.g. council reviewers), `info.agent` on the
-     * child session identifies which subagent started it, so we can
-     * avoid attributing the child to the wrong pending call.
-     * Falls back to the oldest pending call for the parent when no
-     * agent match is found (preserves prior behavior).
+     * Peek a pending call for a parent, only when it can be identified
+     * unambiguously. The v2 host stamps the child session title with the
+     * tool call's `description` argument, so an exact label match
+     * identifies the originating call even among same-agent parallel
+     * launches. When a title is known but matches no unique pending, the
+     * owning call's pending is already consumed (or labels collide) —
+     * refuse rather than guess, because a wrong pairing mis-attributes
+     * the child session (see docs/superpowers/plans/2026-09-12-
+     * task-session-parallel-pairing.md).
      */
-    peekByParentAndAgent(parentSessionId: string, agentHint?: string) {
-      if (!agentHint) return this.peekByParent(parentSessionId);
-      let fallback: PendingTaskCall | undefined;
+    peekByParentAndAgent(
+      parentSessionId: string,
+      agentHint?: string,
+      title?: string,
+    ) {
+      const unmarked: PendingTaskCall[] = [];
       for (const call of pendingCalls.values()) {
-        if (call.parentSessionId !== parentSessionId) continue;
-        if (call.earlyRegisteredTaskID || call.earlyRegistrationRejected) {
+        if (
+          call.parentSessionId === parentSessionId &&
+          !call.earlyRegisteredTaskID &&
+          !call.earlyRegistrationRejected
+        ) {
+          unmarked.push(call);
+        }
+      }
+      if (unmarked.length === 0) return undefined;
+
+      if (typeof title === 'string' && title !== '') {
+        // Title matching is identity-strong (the host stamps the child
+        // title with the call's description argument), but labels can be
+        // reused across different agents in one parent turn; never claim
+        // a pending whose agent differs from the child session's agent.
+        const byTitle = unmarked.filter(
+          (call) =>
+            call.label === title &&
+            (!agentHint || call.agentType === agentHint),
+        );
+        return byTitle.length === 1 ? byTitle[0] : undefined;
+      }
+
+      if (agentHint) {
+        const byAgent = unmarked.filter((call) => call.agentType === agentHint);
+        if (byAgent.length !== 1) return undefined;
+        // A same-agent call that was already consumed (its after-hook
+        // ran) may be the true owner of this no-title child — its
+        // registration already had its chance, so this event is most
+        // likely stale. Refuse; the caller registers a placeholder.
+        if (hasConsumedFor(parentSessionId, agentHint)) return undefined;
+        return byAgent[0];
+      }
+
+      if (unmarked.length === 1) {
+        if (hasConsumedFor(parentSessionId)) return undefined;
+        return unmarked[0];
+      }
+      return undefined;
+    },
+
+    hasConsumedCall(parentSessionId: string, agentType?: string): boolean {
+      return hasConsumedFor(parentSessionId, agentType);
+    },
+
+    takeByTaskID(
+      parentSessionId: string,
+      taskID: string,
+      ownerBoard?: BackgroundJobStore,
+    ) {
+      for (const [callId, call] of pendingCalls.entries()) {
+        if (
+          call.parentSessionId !== parentSessionId ||
+          call.earlyRegisteredTaskID !== taskID
+        ) {
           continue;
         }
-        if (!fallback) fallback = call;
-        if (call.agentType === agentHint) return call;
+        if (
+          call.earlyRegistration &&
+          ownerBoard &&
+          call.earlyRegistration.backgroundJobBoard !== ownerBoard
+        ) {
+          return undefined;
+        }
+        pendingCalls.delete(callId);
+        recordConsumed(call);
+        return call;
       }
-      return fallback;
+      return undefined;
     },
 
     adoptEarlyRegistrations(
@@ -206,6 +341,11 @@ export function createPendingCallTracker(
         pendingCalls.delete(callId);
         removed.push(pending);
       }
+      for (const [callId, consumed] of consumedCalls.entries()) {
+        if (consumed.parentSessionId === sessionId) {
+          consumedCalls.delete(callId);
+        }
+      }
       // Release queued tickets before active tickets. Releasing an active
       // ticket pumps the scheduler, so doing it in insertion order could
       // admit a later call just as the parent is being deleted.
@@ -214,9 +354,10 @@ export function createPendingCallTracker(
       }
     },
 
-    clearAll() {
+    clearAll(): void {
       const removed = [...pendingCalls.values()].reverse();
       pendingCalls.clear();
+      consumedCalls.clear();
       for (const pending of removed) releaseCallLease(pending);
     },
 

+ 59 - 6
src/hooks/task-session-manager/tool-execute-hooks.ts

@@ -74,7 +74,12 @@ export async function handleToolExecuteBefore(
     backgroundJobBoard: BackgroundJobStore;
     pendingCallTracker: {
       add(call: PendingTaskCall): void;
-      take(callID?: string, sessionID?: string): PendingTaskCall | undefined;
+      take(
+        callID?: string,
+        sessionID?: string,
+        ownerBoard?: BackgroundJobStore,
+        options?: { recordConsumed?: boolean },
+      ): PendingTaskCall | undefined;
       release?(call: PendingTaskCall): void;
       pendingCallId(sessionID?: string, callID?: string): string;
     };
@@ -245,7 +250,14 @@ export async function handleToolExecuteBefore(
       }
     }
   } catch (error) {
-    const tracked = deps.pendingCallTracker.take(pendingCall.callId);
+    const tracked = deps.pendingCallTracker.take(
+      pendingCall.callId,
+      undefined,
+      undefined,
+      {
+        recordConsumed: false,
+      },
+    );
     if (tracked) deps.pendingCallTracker.release?.(tracked);
     else pendingCall.concurrencyTicket?.releaseIfUnbound();
     throw error;
@@ -274,6 +286,12 @@ export async function handleToolExecuteAfter(
         callID?: string,
         sessionID?: string,
         ownerBoard?: BackgroundJobStore,
+        options?: { recordConsumed?: boolean },
+      ): PendingTaskCall | undefined;
+      takeByTaskID(
+        sessionID: string,
+        taskID: string,
+        ownerBoard?: BackgroundJobStore,
       ): PendingTaskCall | undefined;
       release?(call: PendingTaskCall): void;
     };
@@ -317,13 +335,33 @@ export async function handleToolExecuteAfter(
     typeof input.callID === 'string' && input.callID.trim() !== ''
       ? input.callID
       : undefined;
-  const pending = deps.pendingCallTracker.take(
+  let pending = deps.pendingCallTracker.take(
     exactCallID,
     exactCallID ? undefined : input.sessionID,
     deps.backgroundJobBoard,
   );
   const exactCallConfirmed =
     exactCallID !== undefined && pending?.callId === exactCallID;
+  if (!pending && typeof output.output === 'string') {
+    // No tool call ID (or unknown one): resolve identity via the task
+    // ID parsed from this call's own output, matched against the
+    // pending the early registration claimed for that child. This
+    // avoids guessing by insertion order among parallel calls.
+    const identityTaskID = parseTaskIdFromTaskOutput(output.output);
+    if (identityTaskID && input.sessionID) {
+      pending = deps.pendingCallTracker.takeByTaskID(
+        input.sessionID,
+        identityTaskID,
+        deps.backgroundJobBoard,
+      );
+      if (pending) {
+        log(
+          '[task-session-manager] resolved task output identity via early-registered task ID',
+          { taskID: identityTaskID, callID: pending.callId },
+        );
+      }
+    }
+  }
   log('[task-session-manager] tool.execute.after task', {
     callID: input.callID,
     sessionID: input.sessionID,
@@ -341,10 +379,9 @@ export async function handleToolExecuteAfter(
     if (typeof output.output !== 'string') return;
     if (pending.earlyRegistrationRejected) {
       log(
-        '[task-session-manager] ignored task output after fenced early registration',
+        '[task-session-manager] task output previously fenced; re-evaluating registration against board state',
         { callID: pending.callId },
       );
-      return;
     }
 
     const launch = parseTaskLaunchOutput(output.output);
@@ -516,7 +553,23 @@ function registerTaskOutputLaunch(
     );
     return undefined;
   }
-  if (pending.earlyRegisteredTaskID && !existing) return undefined;
+  if (
+    pending.earlyRegisteredTaskID &&
+    pending.earlyRegisteredTaskID !== taskID &&
+    !existing
+  ) {
+    // The pending was cross-marked by another child's session.created
+    // (parallel same-agent launches). The taskID parsed from THIS call's
+    // own output is authoritative — register it instead of dropping.
+    log(
+      '[task-session-manager] registering authoritative task ID despite cross-marked pending',
+      {
+        taskID,
+        crossMarkedTaskID: pending.earlyRegisteredTaskID,
+        callID: pending.callId,
+      },
+    );
+  }
 
   try {
     return deps.backgroundJobBoard.registerLaunch({