Преглед на файлове

Merge pull request #1167 from GoldJohnKing/chore/tsm-micro-cleanup

chore(task-session-manager): drop generation fence, dead branch, and per-event board sort
Alvin преди 4 дни
родител
ревизия
55742e3005

Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
src/hooks/task-session-manager/codemap.md


+ 2 - 5
src/hooks/task-session-manager/idle-reconciliation.ts

@@ -348,11 +348,8 @@ export function createIdleReconciler(options: {
       errorTerminalizeTimers.set(sessionID, timer);
     };
 
-    if (options.isFallbackInProgress?.(sessionID)) {
-      // Fallback in flight when we first schedule — start watching anyway.
-      schedule();
-      return;
-    }
+    // Schedule even when a fallback is already in flight: the timer
+    // callback reschedules until the fallback outcome is known.
     schedule();
   }
 

+ 27 - 28
src/hooks/task-session-manager/index.ts

@@ -17,6 +17,33 @@ import {
 import { extractChildTerminalEvidence } from '../../utils/child-transcript';
 import { isRecord as isObjectRecord } from '../../utils/guards';
 import { getClient } from '../../utils/opencode-client';
+import type { SessionLifecycle } from '../session-lifecycle';
+import { isMessageWithParts, isUserMessageWithParts } from '../types';
+import {
+  BACKGROUND_JOB_BOARD_METADATA_KEY,
+  type InjectedTerminalJobs,
+  type InjectionState,
+  injectBackgroundJobBoard,
+  observeSyntheticTerminalPart,
+  reconcileInjectedTerminalJobs,
+  stabilizeRunningTaskParts,
+  updateFromInjectedCompletion,
+} from './board-injection';
+import { handleEvent } from './event-router';
+import { createIdleReconciler } from './idle-reconciliation';
+import { createIdleSessionTokens } from './idle-session-tokens';
+import { createInputWaitTracker } from './input-wait-tracker';
+import {
+  createPendingCallTracker,
+  type PendingCallTracker,
+} from './pending-call-tracker';
+import type { RevivedRunTracker } from './revived-run-tracker';
+import { createRuntimeStatusReconciler } from './runtime-status-reconciliation';
+import { createTaskContextTracker } from './task-context-tracker';
+import {
+  handleToolExecuteAfter,
+  handleToolExecuteBefore,
+} from './tool-execute-hooks';
 
 /** Extract the final assistant text from a child session transcript
  * (v1-shaped {data:[{info,parts}]} via the client shim's messages). Used
@@ -51,34 +78,6 @@ async function readFinalAssistantText(
   }
 }
 
-import type { SessionLifecycle } from '../session-lifecycle';
-import { isMessageWithParts, isUserMessageWithParts } from '../types';
-import {
-  BACKGROUND_JOB_BOARD_METADATA_KEY,
-  type InjectedTerminalJobs,
-  type InjectionState,
-  injectBackgroundJobBoard,
-  observeSyntheticTerminalPart,
-  reconcileInjectedTerminalJobs,
-  stabilizeRunningTaskParts,
-  updateFromInjectedCompletion,
-} from './board-injection';
-import { handleEvent } from './event-router';
-import { createIdleReconciler } from './idle-reconciliation';
-import { createIdleSessionTokens } from './idle-session-tokens';
-import { createInputWaitTracker } from './input-wait-tracker';
-import {
-  createPendingCallTracker,
-  type PendingCallTracker,
-} from './pending-call-tracker';
-import type { RevivedRunTracker } from './revived-run-tracker';
-import { createRuntimeStatusReconciler } from './runtime-status-reconciliation';
-import { createTaskContextTracker } from './task-context-tracker';
-import {
-  handleToolExecuteAfter,
-  handleToolExecuteBefore,
-} from './tool-execute-hooks';
-
 export { BACKGROUND_JOB_BOARD_METADATA_KEY } from './board-injection';
 
 /**

+ 339 - 1
src/hooks/task-session-manager/parallel-same-agent-pairing.test.ts

@@ -1,10 +1,22 @@
 import { describe, expect, mock, test } from 'bun:test';
+import * as fsp from 'node:fs/promises';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { pathToFileURL } from 'node:url';
 import { BackgroundJobBoard } from '../../utils/background-job-board';
+import { BackgroundTaskConcurrency } from '../../utils/background-task-concurrency';
 import { createTaskSessionManagerHook } from './index';
+import { createPendingCallTracker } from './pending-call-tracker';
 
 const PARENT = 'parent-1';
 
-function createHook(board: BackgroundJobBoard) {
+function createHook(
+  board: BackgroundJobBoard,
+  extra: {
+    backgroundTaskConcurrency?: BackgroundTaskConcurrency;
+    pendingCallTracker?: ReturnType<typeof createPendingCallTracker>;
+  } = {},
+) {
   return createTaskSessionManagerHook(
     {
       client: { session: { status: mock(async () => ({ data: {} })) } },
@@ -15,6 +27,7 @@ function createHook(board: BackgroundJobBoard) {
       maxSessionsPerAgent: 2,
       backgroundJobBoard: board,
       shouldManageSession: () => true,
+      ...extra,
     },
   );
 }
@@ -259,4 +272,329 @@ describe('parallel same-agent pairing (incident 2026-09-12)', () => {
     expect(board.get(sA)?.description).toBe(L_A);
     expect(board.get(sB)?.description).toBe(L_B);
   });
+
+  test('no-callID no-title parallel burst drains instead of poisoning the parent (B1)', async () => {
+    const board = new BackgroundJobBoard();
+    const concurrency = new BackgroundTaskConcurrency({
+      defaultConcurrency: 2,
+      providerConcurrency: {},
+      modelConcurrency: {},
+    });
+    const tracker = createPendingCallTracker();
+    const hook = createHook(board, {
+      backgroundTaskConcurrency: concurrency,
+      pendingCallTracker: tracker,
+    });
+    const sA = 'ses_aaaa1111';
+    const sB = 'ses_bbbb2222';
+    const sC = 'ses_dddd4444';
+    const completed = (taskID: string) =>
+      [
+        `task_id: ${taskID}`,
+        'state: completed',
+        '',
+        '<task_result>',
+        'Review finished.',
+        '</task_result>',
+      ].join('\n');
+    const noIDBefore = (description: string) =>
+      [
+        { tool: 'task', sessionID: PARENT },
+        {
+          args: {
+            subagent_type: 'oracle',
+            description,
+            prompt: 'do the review',
+            background: true,
+          },
+        },
+      ] as const;
+
+    // Parallel burst WITHOUT callIDs and WITHOUT titles: both
+    // background admissions take their concurrency tickets.
+    await hook['tool.execute.before'](...noIDBefore(L_A));
+    await hook['tool.execute.before'](...noIDBefore(L_B));
+    expect(concurrency.snapshot()).toEqual({ active: 2, queued: 0 });
+
+    // No-title children are ambiguous → placeholders claim no pending.
+    await hook.event(created({ child: sA }));
+    await hook.event(created({ child: sB }));
+    expect(board.get(sA)?.description).toBe('unattributed oracle task');
+    expect(board.get(sB)?.description).toBe('unattributed oracle task');
+
+    // after A parses sA from its own output: take() refuses (2
+    // pendings), takeByTaskID misses (nothing claimed sA) → the drain
+    // fallback consumes exactly one pending and the output flows
+    // through the normal ticket-release path.
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: PARENT },
+      { output: completed(sA) },
+    );
+    expect(tracker.peekByParent(PARENT)?.callId).toBe('parent-1:anonymous-2');
+    // Ticket A was bound to sA and released on the terminal status —
+    // only B's admission slot remains held.
+    expect(concurrency.snapshot()).toEqual({ active: 1, queued: 0 });
+    // The board record keeps everything output-authoritative (task ID,
+    // state, result text from A's own output), but the drained pending
+    // was consumed without verified identity — its label/objective may
+    // belong to sibling B — so the metadata floor applies: the record
+    // keeps the honest placeholder instead of a possibly-wrong label.
+    expect(board.get(sA)?.description).toBe('unattributed oracle task');
+    expect(board.get(sA)?.state).toBe('completed');
+    expect(board.get(sA)?.resultSummary).toBe('Review finished.');
+
+    // after B resolves through the normal sole-survivor take — the
+    // burst did not strand anything or poison the parent. The take is
+    // still flagged: A's unresolved drain shifted the sole-survivor
+    // window, so B's label cannot be trusted either.
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: PARENT },
+      { output: completed(sB) },
+    );
+    expect(board.get(sB)?.description).toBe('unattributed oracle task');
+    expect(board.get(sB)?.state).toBe('completed');
+    expect(board.get(sB)?.resultSummary).toBe('Review finished.');
+    expect(concurrency.snapshot()).toEqual({ active: 0, queued: 0 });
+
+    // A subsequent no-ID call for this parent still works end-to-end.
+    // The parent's unresolved window persists for the session, so the
+    // sole take is flagged as well; with no placeholder record for sC
+    // the fresh registration falls back to registerLaunch's generic
+    // default label.
+    await hook['tool.execute.before'](...noIDBefore(L_C));
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: PARENT },
+      { output: HOST_LAUNCH(sC) },
+    );
+    expect(board.get(sC)?.description).toBe('background oracle task');
+  });
+
+  test('eviction variant: pre-consumed pending degrades the burst without corrupting it (B)', async () => {
+    const board = new BackgroundJobBoard();
+    const tracker = createPendingCallTracker();
+    const hook = createHook(board, { pendingCallTracker: tracker });
+    const sA = 'ses_aaaa1111';
+    const sB = 'ses_bbbb2222';
+    const sC = 'ses_dddd4444';
+    const completed = (taskID: string) =>
+      [
+        `task_id: ${taskID}`,
+        'state: completed',
+        '',
+        '<task_result>',
+        'Review finished.',
+        '</task_result>',
+      ].join('\n');
+    const noIDBefore = (description: string) =>
+      [
+        { tool: 'task', sessionID: PARENT },
+        {
+          args: {
+            subagent_type: 'oracle',
+            description,
+            prompt: 'do the review',
+            background: true,
+          },
+        },
+      ] as const;
+
+    // Burst of three no-ID, no-title calls; the pending cap evicts the
+    // oldest pending (its ticket was released at eviction —
+    // pre-existing behavior) before any after-hook fires.
+    await hook['tool.execute.before'](...noIDBefore(L_A));
+    await hook['tool.execute.before'](...noIDBefore(L_B));
+    await hook['tool.execute.before'](...noIDBefore(L_C));
+    tracker.take('parent-1:anonymous-1');
+
+    // No-title children are ambiguous → placeholders claim no pending.
+    await hook.event(created({ child: sA }));
+    await hook.event(created({ child: sB }));
+    await hook.event(created({ child: sC }));
+    expect(board.get(sA)?.description).toBe('unattributed oracle task');
+    expect(board.get(sB)?.description).toBe('unattributed oracle task');
+    expect(board.get(sC)?.description).toBe('unattributed oracle task');
+
+    // The evicted call's late after-hook: two pendings remain, so
+    // take() refuses and the drain fallback consumes B's pending —
+    // flagged unresolved, so sA never receives a sibling label.
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: PARENT },
+      { output: completed(sA) },
+    );
+    expect(board.get(sA)?.description).toBe('unattributed oracle task');
+    expect(board.get(sA)?.state).toBe('completed');
+    expect(board.get(sA)?.resultSummary).toBe('Review finished.');
+
+    // The sibling's after steals the shifted window (sole survivor C,
+    // armed by the drain) — flagged too, so sB also stays generic.
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: PARENT },
+      { output: completed(sB) },
+    );
+    expect(board.get(sB)?.description).toBe('unattributed oracle task');
+    expect(board.get(sB)?.state).toBe('completed');
+    expect(board.get(sB)?.resultSummary).toBe('Review finished.');
+
+    // C's own after arrives last: no pending remains (its pending was
+    // consumed by B's window-shifted take), so its output drains
+    // nothing and drops. The cascade terminates degraded — sC keeps
+    // its honest placeholder instead of a stolen or poisoned record —
+    // and nothing is stranded.
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: PARENT },
+      { output: completed(sC) },
+    );
+    expect(board.get(sC)?.description).toBe('unattributed oracle task');
+    expect(board.get(sC)?.state).toBe('running');
+    expect(tracker.peekByParent(PARENT)).toBeUndefined();
+  });
+
+  test('B1 drain fallback logs exactly one deterministic warning per burst', async () => {
+    // Log-file assertions run in a subprocess: other test files
+    // mock.module('../../utils/logger') globally in shared-process
+    // runs, so the real logger is only observable with a pristine
+    // module registry (same pattern as runtime-status-reconciliation).
+    const logDir = await fsp.mkdtemp(
+      path.join(os.tmpdir(), 'omos-b1-drain-log-'),
+    );
+    const workerSource = `
+      const { createTaskSessionManagerHook } = await import(
+        process.env.HOOK_MODULE_URL
+      );
+      const { BackgroundJobBoard } = await import(
+        process.env.BOARD_MODULE_URL
+      );
+      const { BackgroundTaskConcurrency } = await import(
+        process.env.CONCURRENCY_MODULE_URL
+      );
+      const { initLogger, flushLoggerForTesting } = await import(
+        process.env.LOGGER_MODULE_URL
+      );
+      const { readFileSync } = await import('node:fs');
+      initLogger('drain-fallback-b1');
+      const board = new BackgroundJobBoard();
+      const concurrency = new BackgroundTaskConcurrency({
+        defaultConcurrency: 2,
+        providerConcurrency: {},
+        modelConcurrency: {},
+      });
+      const hook = createTaskSessionManagerHook(
+        {
+          client: { session: { status: async () => ({ data: {} }) } },
+          directory: '/tmp',
+          worktree: '/tmp',
+        },
+        {
+          maxSessionsPerAgent: 2,
+          backgroundJobBoard: board,
+          backgroundTaskConcurrency: concurrency,
+          shouldManageSession: () => true,
+        },
+      );
+      const PARENT = 'parent-1';
+      const L_A = 'Review v2 compat layer PRs';
+      const L_B = 'Review wake/synthetic PR chain';
+      const completed = (taskID) =>
+        [
+          'task_id: ' + taskID,
+          'state: completed',
+          '',
+          '<task_result>',
+          'Review finished.',
+          '</task_result>',
+        ].join('\\n');
+      const created = (child) => ({
+        event: {
+          type: 'session.created',
+          properties: { info: { id: child, parentID: PARENT, agent: 'oracle' } },
+        },
+      });
+      const before = (description) => [
+        { tool: 'task', sessionID: PARENT },
+        {
+          args: {
+            subagent_type: 'oracle',
+            description,
+            prompt: 'do the review',
+            background: true,
+          },
+        },
+      ];
+      await hook['tool.execute.before'](...before(L_A));
+      await hook['tool.execute.before'](...before(L_B));
+      await hook.event(created('ses_aaaa1111'));
+      await hook.event(created('ses_bbbb2222'));
+      await hook['tool.execute.after'](
+        { tool: 'task', sessionID: PARENT },
+        { output: completed('ses_aaaa1111') },
+      );
+      await hook['tool.execute.after'](
+        { tool: 'task', sessionID: PARENT },
+        { output: completed('ses_bbbb2222') },
+      );
+      await flushLoggerForTesting();
+      const lines = readFileSync(process.env.LOG_FILE_PATH, 'utf8').split(
+        '\\n',
+      );
+      console.log(
+        JSON.stringify({
+          drainWarnings: lines.filter((line) =>
+            line.includes(
+              'unresolvable no-ID take; consuming first-match pending (drain fallback)',
+            ),
+          ).length,
+          identityResolutions: lines.filter((line) =>
+            line.includes(
+              'resolved task output identity via early-registered task ID',
+            ),
+          ).length,
+        }),
+      );
+    `;
+    const proc = Bun.spawn([process.execPath, '-e', workerSource], {
+      cwd: import.meta.dir,
+      env: {
+        ...process.env,
+        OPENCODE_LOG_DIR: logDir,
+        HOOK_MODULE_URL: pathToFileURL(path.join(import.meta.dir, 'index.ts'))
+          .href,
+        BOARD_MODULE_URL: pathToFileURL(
+          path.join(import.meta.dir, '../../utils/background-job-board.ts'),
+        ).href,
+        CONCURRENCY_MODULE_URL: pathToFileURL(
+          path.join(
+            import.meta.dir,
+            '../../utils/background-task-concurrency.ts',
+          ),
+        ).href,
+        LOGGER_MODULE_URL: pathToFileURL(
+          path.join(import.meta.dir, '../../utils/logger.ts'),
+        ).href,
+        LOG_FILE_PATH: path.join(
+          logDir,
+          'oh-my-opencode-slim.drain-fallback-b1.log',
+        ),
+      },
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    const [stdout, stderr, exitCode] = await Promise.all([
+      new Response(proc.stdout).text(),
+      new Response(proc.stderr).text(),
+      proc.exited,
+    ]);
+    await fsp.rm(logDir, { recursive: true, force: true });
+    if (exitCode !== 0) {
+      console.error(stderr);
+      expect(exitCode).toBe(0);
+    }
+    const counts = JSON.parse(stdout.trim()) as {
+      drainWarnings: number;
+      identityResolutions: number;
+    };
+    // Only after A hits the fallback; after B resolves via the normal
+    // sole-survivor take.
+    expect(counts.drainWarnings).toBe(1);
+    expect(counts.identityResolutions).toBe(0);
+  });
 });

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

@@ -1,4 +1,5 @@
 import { describe, expect, test } from 'bun:test';
+import { BackgroundJobBoard } from '../../utils/background-job-board';
 import {
   createPendingCallTracker,
   type PendingTaskCall,
@@ -150,6 +151,25 @@ describe('take', () => {
 
     expect(taken?.callId).toBe('a');
   });
+
+  test('recordConsumed:false rollback does not arm the staleness guard', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a' }));
+    tracker.add(pending({ callId: 'b' }));
+
+    // A before-hook admission error rolls its own pending back without
+    // recording consumption (tool.execute.before catch path).
+    const rolledBack = tracker.take('a', undefined, undefined, {
+      recordConsumed: false,
+    });
+    expect(rolledBack?.callId).toBe('a');
+    expect(tracker.hasConsumedCall('parent-1')).toBe(false);
+
+    // A later no-title same-agent child is still claimable: the
+    // staleness guard was not poisoned by the rollback.
+    const hit = tracker.peekByParentAndAgent('parent-1', 'oracle');
+    expect(hit?.callId).toBe('b');
+  });
 });
 
 describe('takeByTaskID', () => {
@@ -172,4 +192,183 @@ describe('takeByTaskID', () => {
     expect(tracker.takeByTaskID('parent-1', 'ses_y')).toBeUndefined();
     expect(tracker.take('a')?.callId).toBe('a');
   });
+
+  test('leaves a pending owned by another board instance untouched', () => {
+    const tracker = createPendingCallTracker();
+    const ownerBoard = new BackgroundJobBoard();
+    const otherBoard = new BackgroundJobBoard();
+    tracker.add(
+      pending({
+        callId: 'a',
+        earlyRegisteredTaskID: 'ses_x',
+        earlyRegistration: {
+          taskID: 'ses_x',
+          generation: 1,
+          backgroundJobBoard: otherBoard,
+        },
+      }),
+    );
+
+    // A different board generation's after-hook must not steal the
+    // pending claimed under another generation.
+    expect(
+      tracker.takeByTaskID('parent-1', 'ses_x', ownerBoard),
+    ).toBeUndefined();
+
+    // The owning board generation can still resolve it.
+    const taken = tracker.takeByTaskID('parent-1', 'ses_x', otherBoard);
+    expect(taken?.callId).toBe('a');
+  });
+});
+
+describe('takeUnresolvedFirstMatch', () => {
+  test('drains the oldest unmarked pending and records consumption', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a' }));
+    tracker.add(pending({ callId: 'b' }));
+
+    const taken = tracker.takeUnresolvedFirstMatch('parent-1', {
+      identityTaskID: 'ses_new',
+    });
+
+    expect(taken?.callId).toBe('a');
+    // Consumption is recorded like take(): a later no-title child of
+    // the same agent is treated as possibly stale.
+    expect(tracker.hasConsumedCall('parent-1', 'oracle')).toBe(true);
+    expect(tracker.take('b')?.callId).toBe('b');
+  });
+
+  test('constrains by agent when the child agent is known', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'f1', agentType: 'fixer' }));
+    tracker.add(pending({ callId: 'o1', agentType: 'oracle' }));
+
+    // The output belongs to an oracle child: the older fixer pending
+    // is not consumed.
+    const taken = tracker.takeUnresolvedFirstMatch('parent-1', {
+      identityTaskID: 'ses_x',
+      agentType: 'oracle',
+    });
+
+    expect(taken?.callId).toBe('o1');
+    expect(tracker.take('f1')?.callId).toBe('f1');
+  });
+
+  test('returns undefined when no pending matches the known agent', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'f1', agentType: 'fixer' }));
+
+    expect(
+      tracker.takeUnresolvedFirstMatch('parent-1', {
+        identityTaskID: 'ses_x',
+        agentType: 'oracle',
+      }),
+    ).toBeUndefined();
+    expect(tracker.take('f1')?.callId).toBe('f1');
+  });
+
+  test('never consumes early-registered or rejected pendings', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', earlyRegisteredTaskID: 'ses_x' }));
+    tracker.add(pending({ callId: 'b', earlyRegistrationRejected: true }));
+    tracker.add(pending({ callId: 'c' }));
+
+    expect(tracker.takeUnresolvedFirstMatch('parent-1')?.callId).toBe('c');
+  });
+
+  test('skips resumed pendings pinned to a different task ID', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', resumedTaskId: 'ses_old' }));
+    tracker.add(pending({ callId: 'b' }));
+
+    // 'a' is a relaunch of ses_old; an output carrying ses_new cannot
+    // belong to it, so the drain skips to the next candidate.
+    const taken = tracker.takeUnresolvedFirstMatch('parent-1', {
+      identityTaskID: 'ses_new',
+    });
+    expect(taken?.callId).toBe('b');
+
+    // An output carrying the resumed ID may still drain it.
+    const pinned = createPendingCallTracker();
+    pinned.add(pending({ callId: 'a', resumedTaskId: 'ses_old' }));
+    expect(
+      pinned.takeUnresolvedFirstMatch('parent-1', {
+        identityTaskID: 'ses_old',
+      })?.callId,
+    ).toBe('a');
+  });
+
+  test('returns undefined for a parent with no pendings', () => {
+    const tracker = createPendingCallTracker();
+
+    expect(
+      tracker.takeUnresolvedFirstMatch('parent-1', { identityTaskID: 'ses_x' }),
+    ).toBeUndefined();
+  });
+
+  test('flags the drained call unresolved and arms the parent window', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a' }));
+    tracker.add(pending({ callId: 'b' }));
+
+    const taken = tracker.takeUnresolvedFirstMatch('parent-1', {
+      identityTaskID: 'ses_x',
+    });
+
+    expect(taken?.callId).toBe('a');
+    expect(taken?.identityUnresolved).toBe(true);
+
+    // Window-shift propagation: the later no-callId sole take for the
+    // same parent is flagged too — "sole survivor" no longer proves
+    // identity once an unresolved drain shifted the ordering argument.
+    const sole = tracker.take(undefined, 'parent-1');
+    expect(sole?.callId).toBe('b');
+    expect(sole?.identityUnresolved).toBe(true);
+  });
+
+  test('armed window never flags a claim-verified takeByTaskID take', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', earlyRegisteredTaskID: 'ses_claimed' }));
+    tracker.add(pending({ callId: 'b' }));
+
+    // Drain the unmarked pending (a is fenced by its early-registration
+    // claim), arming the parent's unresolved window.
+    expect(
+      tracker.takeUnresolvedFirstMatch('parent-1', {
+        identityTaskID: 'ses_x',
+      })?.callId,
+    ).toBe('b');
+
+    // A takeByTaskID take is identity-verified by the early
+    // registration's claim: no unresolved flag.
+    const claimed = tracker.takeByTaskID('parent-1', 'ses_claimed');
+    expect(claimed?.callId).toBe('a');
+    expect(claimed?.identityUnresolved).toBeUndefined();
+  });
+
+  test('clearSession resets the unresolved window for that parent', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a' }));
+    tracker.takeUnresolvedFirstMatch('parent-1', { identityTaskID: 'ses_x' });
+
+    tracker.clearSession('parent-1');
+
+    // A fresh pending in the cleared window resolves normally.
+    tracker.add(pending({ callId: 'b' }));
+    const sole = tracker.take(undefined, 'parent-1');
+    expect(sole?.callId).toBe('b');
+    expect(sole?.identityUnresolved).toBeUndefined();
+  });
+
+  test('clearAll resets every unresolved window', () => {
+    const tracker = createPendingCallTracker();
+    tracker.add(pending({ callId: 'a', parentSessionId: 'parent-1' }));
+    tracker.takeUnresolvedFirstMatch('parent-1', { identityTaskID: 'ses_x' });
+
+    tracker.clearAll();
+
+    tracker.add(pending({ callId: 'b', parentSessionId: 'parent-1' }));
+    const sole = tracker.take(undefined, 'parent-1');
+    expect(sole?.identityUnresolved).toBeUndefined();
+  });
 });

+ 102 - 5
src/hooks/task-session-manager/pending-call-tracker.ts

@@ -29,6 +29,10 @@ export interface PendingTaskCall {
   earlyRegisteredTaskID?: string;
   earlyRegistration?: EarlyTaskRegistration;
   earlyRegistrationRejected?: boolean;
+  /** Consumed without verified call identity (no-ID drain fallback or a
+   *  window-shifted sole take): the label/objective may belong to a
+   *  sibling call and must not be painted onto the board record. */
+  identityUnresolved?: boolean;
 }
 
 const MAX_PENDING_TASK_CALLS = 100;
@@ -51,6 +55,29 @@ export interface PendingCallTracker {
     taskID: string,
     ownerBoard?: BackgroundJobStore,
   ): PendingTaskCall | undefined;
+  /** Guarded drain fallback for no-tool-call-ID hosts: when neither a
+   *  call ID nor an early-registration claim could identify the
+   *  pending, remove and return the OLDEST unmarked pending for the
+   *  parent — constrained to `agentType` when the child's agent is
+   *  known, and never a resumed pending pinned to a different
+   *  `identityTaskID` (that pending provably belongs to another call).
+   *  Pendings claimed by an early registration or fenced for another
+   *  board generation are left for their owners. Consumption is
+   *  recorded exactly like `take()`. The consumed call is marked
+   *  `identityUnresolved` and arms the parent's unresolved window, so
+   *  later no-callID sole-survivor takes for the same parent are
+   *  flagged too. Returns undefined when no eligible pending exists. */
+  takeUnresolvedFirstMatch(
+    parentSessionId: string,
+    selection?: {
+      /** Task ID parsed from the consuming call's own output. */
+      identityTaskID?: string;
+      /** Agent of the child session that produced that output. */
+      agentType?: string;
+      /** Board-generation fence, same as take()/takeByTaskID(). */
+      ownerBoard?: BackgroundJobStore;
+    },
+  ): PendingTaskCall | undefined;
   release(call: PendingTaskCall): void;
   peekByParent(parentSessionId: string): PendingTaskCall | undefined;
   peekByParentAndAgent(
@@ -78,6 +105,12 @@ export function createPendingCallTracker(
   const pendingCalls = new Map<string, PendingTaskCall>();
   let anonymousPendingCallId = 0;
 
+  /** Parents where a pending was consumed through the unresolved-identity
+   *  drain fallback. The sole-survivor argument for a later no-callID
+   *  take only holds while every prior take was resolved; one unresolved
+   *  drain shifts the window, so subsequent sole takes are flagged too. */
+  const unresolvedDrainParents = new Set<string>();
+
   /** 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
@@ -155,14 +188,26 @@ export function createPendingCallTracker(
     ) {
       if (!callId && parentSessionId) {
         // 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.
+        // one pending exists for the parent. "A sole survivor belongs
+        // to this call" is a sequential argument — it holds when the
+        // parent's other after-hooks already consumed their pendings,
+        // not during a parallel burst where several after-hooks race.
+        // With several candidates, guessing by insertion order would
+        // mis-attribute the label and could overwrite an
+        // already-correct record, so the take refuses; the caller
+        // resolves identity via takeByTaskID (early-registration
+        // claim) or, failing that, drains one pending through the
+        // guarded takeUnresolvedFirstMatch fallback.
         const sole = solePendingIdForParent(parentSessionId);
         if (!sole) return undefined;
         callId = sole;
+        // Window-shift propagation: an unresolved drain earlier in this
+        // parent's burst means "sole survivor belongs to this call" no
+        // longer proves identity — flag the taken pending unresolved.
+        if (unresolvedDrainParents.has(parentSessionId)) {
+          const solePending = pendingCalls.get(sole);
+          if (solePending) solePending.identityUnresolved = true;
+        }
       }
       if (!callId) return undefined;
       const pending = pendingCalls.get(callId);
@@ -287,6 +332,56 @@ export function createPendingCallTracker(
       return undefined;
     },
 
+    takeUnresolvedFirstMatch(
+      parentSessionId: string,
+      selection?: {
+        identityTaskID?: string;
+        agentType?: string;
+        ownerBoard?: BackgroundJobStore;
+      },
+    ) {
+      for (const [callId, call] of pendingCalls.entries()) {
+        if (call.parentSessionId !== parentSessionId) continue;
+        // Only unmarked pendings are eligible: an early-registration
+        // claim (or its rejection fence) ties the pending to another
+        // resolution path that must keep working.
+        if (call.earlyRegisteredTaskID || call.earlyRegistrationRejected) {
+          continue;
+        }
+        // A resumed pending pinned to a different task ID belongs to a
+        // call whose own output carries that ID.
+        if (
+          selection?.identityTaskID !== undefined &&
+          call.resumedTaskId !== undefined &&
+          call.resumedTaskId !== selection.identityTaskID
+        ) {
+          continue;
+        }
+        if (
+          selection?.agentType !== undefined &&
+          call.agentType !== selection.agentType
+        ) {
+          continue;
+        }
+        if (
+          call.earlyRegistration &&
+          selection?.ownerBoard &&
+          call.earlyRegistration.backgroundJobBoard !== selection.ownerBoard
+        ) {
+          continue;
+        }
+        pendingCalls.delete(callId);
+        recordConsumed(call);
+        // Identity was not verified: the consumed pending's metadata may
+        // belong to a sibling call, and the parent's sole-survivor
+        // window has shifted for any later no-callID take.
+        call.identityUnresolved = true;
+        unresolvedDrainParents.add(parentSessionId);
+        return call;
+      }
+      return undefined;
+    },
+
     adoptEarlyRegistrations(
       backgroundJobBoard: BackgroundJobStore,
       backgroundJobSupervisor?: BackgroundJobSupervisor,
@@ -346,6 +441,7 @@ export function createPendingCallTracker(
           consumedCalls.delete(callId);
         }
       }
+      unresolvedDrainParents.delete(sessionId);
       // 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.
@@ -358,6 +454,7 @@ export function createPendingCallTracker(
       const removed = [...pendingCalls.values()].reverse();
       pendingCalls.clear();
       consumedCalls.clear();
+      unresolvedDrainParents.clear();
       for (const pending of removed) releaseCallLease(pending);
     },
 

+ 1 - 3
src/hooks/task-session-manager/runtime-status-reconciliation.ts

@@ -63,9 +63,7 @@ export function createRuntimeStatusReconciler(options: {
       return;
     }
     if (timer) return;
-    if (
-      !options.backgroundJobBoard.list().some((job) => job.state === 'running')
-    ) {
+    if (!options.backgroundJobBoard.hasRunningJobs()) {
       return;
     }
     timer = setTimeout(() => {

+ 62 - 33
src/hooks/task-session-manager/tool-execute-hooks.ts

@@ -35,36 +35,10 @@ interface TaskArgs {
   background?: unknown;
 }
 
-const earlyRegistrationGenerations = new WeakMap<PendingTaskCall, number>();
 function normalizeObjectiveKey(value: string): string {
   return value.replace(/\s+/g, ' ').trim().toLowerCase();
 }
 
-/**
- * session.created writes earlyRegisteredTaskID through the pending-call
- * object. Capture the generation at that boundary so a delayed native result
- * cannot reuse the current record after a same-ID relaunch.
- */
-function installEarlyRegistrationGenerationFence(
-  pending: PendingTaskCall,
-  backgroundJobBoard: BackgroundJobStore,
-): void {
-  let earlyRegisteredTaskID = pending.earlyRegisteredTaskID;
-  Object.defineProperty(pending, 'earlyRegisteredTaskID', {
-    configurable: true,
-    enumerable: true,
-    get: () => earlyRegisteredTaskID,
-    set: (taskID: string | undefined) => {
-      earlyRegisteredTaskID = taskID;
-      if (!taskID) return;
-      const generation = backgroundJobBoard.get(taskID)?.generation;
-      if (generation !== undefined) {
-        earlyRegistrationGenerations.set(pending, generation);
-      }
-    },
-  });
-}
-
 export async function handleToolExecuteBefore(
   input: { tool: string; sessionID?: string; callID?: string },
   output: { args?: unknown },
@@ -147,7 +121,6 @@ export async function handleToolExecuteBefore(
       typeof args.description === 'string' ? args.description : undefined,
     prompt: typeof args.prompt === 'string' ? args.prompt : undefined,
   });
-  installEarlyRegistrationGenerationFence(pendingCall, deps.backgroundJobBoard);
   if (typeof args.task_id === 'string' && args.task_id.trim() !== '') {
     const requested = args.task_id.trim();
     const remembered =
@@ -293,6 +266,14 @@ export async function handleToolExecuteAfter(
         taskID: string,
         ownerBoard?: BackgroundJobStore,
       ): PendingTaskCall | undefined;
+      takeUnresolvedFirstMatch(
+        sessionID: string,
+        selection?: {
+          identityTaskID?: string;
+          agentType?: string;
+          ownerBoard?: BackgroundJobStore;
+        },
+      ): PendingTaskCall | undefined;
       release?(call: PendingTaskCall): void;
     };
     taskContextTracker: {
@@ -342,12 +323,13 @@ export async function handleToolExecuteAfter(
   );
   const exactCallConfirmed =
     exactCallID !== undefined && pending?.callId === exactCallID;
+  let identityTaskID: string | undefined;
   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);
+    identityTaskID = parseTaskIdFromTaskOutput(output.output);
     if (identityTaskID && input.sessionID) {
       pending = deps.pendingCallTracker.takeByTaskID(
         input.sessionID,
@@ -362,6 +344,39 @@ export async function handleToolExecuteAfter(
       }
     }
   }
+  if (!pending && !exactCallID && identityTaskID && input.sessionID) {
+    // Both identity sources missed: a parallel no-callID burst where
+    // no early registration claimed the parsed task ID. Returning
+    // here would strand a pending — its concurrency ticket never
+    // releases, and sole-survivor takes refuse forever while it
+    // remains (parent poisoning). The task ID parsed from this call's
+    // own output is authoritative, so drain the oldest eligible
+    // pending through the guarded first-match fallback and let the
+    // normal try/finally path release the ticket and process output.
+    const childRecord = deps.backgroundJobBoard.get(identityTaskID);
+    const childAgent =
+      childRecord && childRecord.parentSessionID === input.sessionID
+        ? childRecord.agent
+        : undefined;
+    pending = deps.pendingCallTracker.takeUnresolvedFirstMatch(
+      input.sessionID,
+      {
+        identityTaskID,
+        agentType: childAgent,
+        ownerBoard: deps.backgroundJobBoard,
+      },
+    );
+    if (pending) {
+      log(
+        '[task-session-manager] unresolvable no-ID take; consuming first-match pending (drain fallback)',
+        {
+          taskID: identityTaskID,
+          callID: pending.callId,
+          consumedAgent: pending.agentType,
+        },
+      );
+    }
+  }
   log('[task-session-manager] tool.execute.after task', {
     callID: input.callID,
     sessionID: input.sessionID,
@@ -523,9 +538,7 @@ function registerTaskOutputLaunch(
   if (resumed && pending.resumedTaskId !== taskID) return undefined;
 
   const existing = deps.backgroundJobBoard.get(taskID);
-  const earlyRegistrationGeneration =
-    pending.earlyRegistration?.generation ??
-    earlyRegistrationGenerations.get(pending);
+  const earlyRegistrationGeneration = pending.earlyRegistration?.generation;
   if (
     pending.earlyRegisteredTaskID === taskID &&
     earlyRegistrationGeneration !== undefined &&
@@ -571,13 +584,29 @@ function registerTaskOutputLaunch(
     );
   }
 
+  if (pending.identityUnresolved) {
+    log(
+      '[task-session-manager] registered authoritative task ID with generic metadata (identity unresolved)',
+      { taskID, callID: pending.callId },
+    );
+  }
+
   try {
     return deps.backgroundJobBoard.registerLaunch({
       taskID,
       parentSessionID: pending.parentSessionId,
       agent: pending.agentType,
-      description: pending.label,
-      objective: pending.fullObjective ?? pending.label,
+      // Identity was unresolved (no-ID drain or window-shifted take):
+      // the label/objective may belong to a sibling call, so never
+      // paint them. Existing placeholder records keep their honest
+      // description; fresh records fall back to registerLaunch's
+      // generic default.
+      ...(pending.identityUnresolved
+        ? {}
+        : {
+            description: pending.label,
+            objective: pending.fullObjective ?? pending.label,
+          }),
       background: exactCallConfirmed && pending.background,
       preserveRun:
         pending.earlyRegisteredTaskID === taskID ||

+ 17 - 0
src/utils/background-job-board.test.ts

@@ -23,6 +23,23 @@ describe('BackgroundJobBoard', () => {
       terminalUnreconciled: false,
     });
     expect(board.hasRunning('parent-1')).toBe(true);
+    expect(board.hasRunningJobs()).toBe(true);
+  });
+  test('hasRunningJobs is false once no job is running', () => {
+    const board = new BackgroundJobBoard();
+    expect(board.hasRunningJobs()).toBe(false);
+    board.registerLaunch({
+      taskID: 'ses_idle',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map config',
+    });
+    board.updateStatus({
+      taskID: 'ses_idle',
+      state: 'completed',
+      resultSummary: 'done',
+    });
+    expect(board.hasRunningJobs()).toBe(false);
   });
   test('markUsed lands strictly after completion even with equal timestamps', () => {
     const board = new BackgroundJobBoard();

+ 7 - 0
src/utils/background-job-board.ts

@@ -1013,6 +1013,13 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     return filtered.sort((a, b) => a.launchedAt - b.launchedAt);
   }
 
+  hasRunningJobs(): boolean {
+    for (const job of this.jobs.values()) {
+      if (job.state === 'running') return true;
+    }
+    return false;
+  }
+
   hasRunning(parentSessionID: string): boolean {
     return this.list(parentSessionID).some((job) => job.state === 'running');
   }

+ 4 - 0
src/utils/background-job-coordinator.ts

@@ -346,6 +346,10 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
     return this.board.list(parentSessionID);
   }
 
+  hasRunningJobs(): boolean {
+    return this.board.hasRunningJobs();
+  }
+
   hasRunning(parentSessionID: string): boolean {
     return this.board.hasRunning(parentSessionID);
   }

+ 2 - 0
src/utils/background-job-store.ts

@@ -221,6 +221,8 @@ export interface BackgroundJobStore {
   ): BackgroundJobRecord | undefined;
   taskIDs(): Set<string>;
   list(parentSessionID?: string): BackgroundJobRecord[];
+  /** Cheap global check for any running job: single pass, no copy or sort. */
+  hasRunningJobs(): boolean;
   hasRunning(parentSessionID: string): boolean;
   hasTerminalUnreconciled(parentSessionID: string): boolean;
   hasConvergenceSignals(taskID: string, threshold?: number): boolean;

Някои файлове не бяха показани, защото твърде много файлове са промени