Browse Source

Merge pull request #848 from adikpb/feat/session-manager-refactor

refactor(task-session-manager): decompose monolithic index into focused subsystems
Alvin 3 weeks ago
parent
commit
872573a534

+ 0 - 1
src/agents/orchestrator.test.ts

@@ -11,5 +11,4 @@ describe('orchestrator prompt', () => {
     expect(prompt).toContain('small bounded set of options');
     expect(prompt).toContain('ordinary dialogue that does not block work');
   });
-
 });

+ 1 - 1
src/cache-safety-tripwire.test.ts

@@ -63,7 +63,7 @@ const ALLOWLIST = new Map<string, string>([
     'Date.now() records lastReadAt for internal recency ordering; formatted prompt output (background job board) is confined to the volatile trailing message.',
   ],
   [
-    'hooks/task-session-manager/index.ts',
+    'hooks/task-session-manager/event-router.ts',
     'Date.now() captures idleObservedAt to detect post-idle busy recovery from foreground-fallback re-prompts; never serialized into prompt content.',
   ],
   [

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

@@ -0,0 +1,293 @@
+/**
+ * Board injection subsystem for task session manager.
+ *
+ * Handles injecting Background Job Board state into the message stream
+ * and processing synthetic injected completions.
+ *
+ * All injection logic must go through the cache-safe helpers in
+ * ../cache-safe-injection.ts to ensure prompt cache safety.
+ */
+import type {
+  BackgroundJobRecord,
+  BackgroundJobStore,
+  ContextFile,
+} from '../../utils';
+import { isInternalInitiatorPart, parseTaskStatusOutput } from '../../utils';
+import { log } from '../../utils/logger';
+import {
+  appendTrailingVolatileMessage,
+  stripTaggedContent,
+} from '../cache-safe-injection';
+import type { MessagePart, MessageWithParts } from '../types';
+import { isUserMessageWithParts } from '../types';
+import {
+  extractTaskSummary,
+  formatCancelledTaskStatusOutput,
+  isLateCancelledTaskError,
+  updateBackgroundJobFromOutput,
+} from './status-utils';
+
+// ── Constants ──────────────────────────────────────────────────────────
+
+export const BACKGROUND_JOB_BOARD_METADATA_KEY =
+  'oh-my-opencode-slim.backgroundJobBoard';
+
+const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
+const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
+
+export const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
+
+// ── State shape ────────────────────────────────────────────────────────
+
+export interface InjectionState {
+  backgroundJobBoard: BackgroundJobStore;
+  processedInjectedCompletions: Set<string>;
+  processedInjectedCompletionOrder: string[];
+  terminalJobsInjectedByParent: Map<string, Set<string>>;
+  maxProcessedInjectedCompletions: number;
+  metadataKey: string;
+  shouldManageSession: (sessionID: string) => boolean;
+  taskContextTracker: {
+    pendingManagedTaskIds: Set<string>;
+    contextFilesForPrompt(taskId: string): ContextFile[];
+    prune(board: { taskIDs(): Set<string> }): void;
+  };
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────
+
+function djb2Hash(str: string): string {
+  let hash = 5381;
+  for (let i = 0; i < str.length; i++) {
+    hash = (hash << 5) + hash + str.charCodeAt(i);
+  }
+  return (hash >>> 0).toString(16).padStart(8, '0');
+}
+
+function createOccurrenceId(
+  part: MessagePart,
+  message: MessageWithParts,
+  partIndex: number,
+): string {
+  if (typeof part.id === 'string') {
+    return part.id;
+  }
+
+  if (typeof message.info.id === 'string') {
+    return `${message.info.id}:${partIndex}`;
+  }
+
+  const sessionID = message.info.sessionID ?? 'unknown';
+  const content = typeof part.text === 'string' ? part.text : '';
+
+  const status = parseTaskStatusOutput(content);
+  if (status) {
+    const stableKey = `${sessionID}:${status.taskID}:${status.state}:${status.result ?? ''}`;
+    const hash = djb2Hash(stableKey);
+    return `anon:${hash}`;
+  }
+
+  const hash = djb2Hash(`${sessionID}:${content}`);
+  return `anon:${hash}`;
+}
+
+// ── Exported functions ─────────────────────────────────────────────────
+
+export function updateFromInjectedCompletion(
+  state: InjectionState,
+  part: MessagePart,
+  message: MessageWithParts,
+  _messageIndex: number,
+  partIndex: number,
+): BackgroundJobRecord | undefined {
+  if (part.type !== 'text' || typeof part.text !== 'string') {
+    return undefined;
+  }
+
+  if (part.synthetic !== true) return undefined;
+
+  const status = parseTaskStatusOutput(part.text);
+  if (!status) {
+    log('[task-session-manager] synthetic part missing task status', {
+      textPreview: part.text.slice(0, 120),
+    });
+    return undefined;
+  }
+  if (status.state !== 'completed' && status.state !== 'error') {
+    return undefined;
+  }
+
+  const summary = extractTaskSummary(part.text);
+  const isCompleted = summary
+    ? BACKGROUND_COMPLETION_COMPLETED.test(summary)
+    : status.state === 'completed';
+  const isFailed = summary
+    ? BACKGROUND_COMPLETION_FAILED.test(summary)
+    : status.state === 'error';
+  if (summary && !isCompleted && !isFailed) return undefined;
+
+  const occurrenceId = createOccurrenceId(part, message, partIndex);
+
+  const existing = state.backgroundJobBoard.get(status.taskID);
+  if (isFailed && isLateCancelledTaskError(existing, status.state)) {
+    part.text = formatCancelledTaskStatusOutput(
+      status.taskID,
+      state.backgroundJobBoard.getResultSummary(status.taskID),
+    );
+    log('[task-session-manager] normalized late cancelled injected failure', {
+      taskID: status.taskID,
+      alias: existing?.alias,
+      parsedState: status.state,
+      boardState: existing?.state,
+      terminalState: existing?.terminalState,
+      result: status.result,
+    });
+    rememberProcessedInjectedCompletion(state, occurrenceId);
+    return existing;
+  }
+
+  if (isCompleted && status.state !== 'completed') return undefined;
+  if (isFailed && status.state !== 'error') return undefined;
+
+  if (state.processedInjectedCompletions.has(occurrenceId)) return undefined;
+
+  const updated = updateBackgroundJobFromOutput(
+    part.text,
+    state.backgroundJobBoard,
+    state.taskContextTracker,
+  );
+  if (!updated) return undefined;
+
+  log('[task-session-manager] processed injected background completion', {
+    taskID: updated.taskID,
+    alias: updated.alias,
+    parentSessionID: updated.parentSessionID,
+    state: updated.state,
+    occurrenceId,
+  });
+
+  rememberProcessedInjectedCompletion(state, occurrenceId);
+  return updated;
+}
+
+export function rememberProcessedInjectedCompletion(
+  state: InjectionState,
+  signature: string,
+): void {
+  state.processedInjectedCompletions.add(signature);
+  state.processedInjectedCompletionOrder.push(signature);
+
+  while (
+    state.processedInjectedCompletionOrder.length >
+    state.maxProcessedInjectedCompletions
+  ) {
+    const evicted = state.processedInjectedCompletionOrder.shift();
+    if (!evicted) break;
+    state.processedInjectedCompletions.delete(evicted);
+  }
+}
+
+export function isMissingRememberedSessionError(output: string): boolean {
+  const firstLine = output.split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
+  return (
+    firstLine.startsWith('[error]') &&
+    firstLine.includes('session') &&
+    (firstLine.includes('not found') || firstLine.includes('no session'))
+  );
+}
+
+export function rememberInjectedTerminalJobs(
+  state: InjectionState,
+  parentSessionID: string,
+): void {
+  const taskIDs = state.backgroundJobBoard
+    .list(parentSessionID)
+    .filter((job) => job.terminalUnreconciled)
+    .map((job) => job.taskID);
+  if (taskIDs.length === 0) return;
+
+  log('[task-session-manager] terminal jobs injected for reconciliation', {
+    parentSessionID,
+    taskIDs,
+  });
+
+  const existing =
+    state.terminalJobsInjectedByParent.get(parentSessionID) ??
+    new Set<string>();
+  for (const taskID of taskIDs) {
+    existing.add(taskID);
+  }
+  state.terminalJobsInjectedByParent.set(parentSessionID, existing);
+}
+
+export function reconcileInjectedTerminalJobs(
+  state: InjectionState,
+  parentSessionID: string,
+): void {
+  const taskIDs = state.terminalJobsInjectedByParent.get(parentSessionID);
+  if (!taskIDs) return;
+
+  log('[task-session-manager] reconciling injected terminal jobs', {
+    parentSessionID,
+    taskIDs: [...taskIDs],
+  });
+
+  for (const taskID of taskIDs) {
+    state.backgroundJobBoard.markReconciled(taskID);
+  }
+  state.terminalJobsInjectedByParent.delete(parentSessionID);
+}
+
+export async function injectBackgroundJobBoard(
+  state: InjectionState,
+  _input: Record<string, never>,
+  output: { messages?: unknown },
+): Promise<void> {
+  const messages = Array.isArray(output.messages) ? output.messages : [];
+
+  // Strip previously injected board content: parts attached to real
+  // messages (legacy placement) and whole synthetic board messages.
+  stripTaggedContent(messages, state.metadataKey);
+
+  for (let i = messages.length - 1; i >= 0; i -= 1) {
+    const message = messages[i];
+    if (!isUserMessageWithParts(message)) continue;
+    if (message.info.agent && message.info.agent !== 'orchestrator') return;
+    if (
+      !message.info.sessionID ||
+      !state.shouldManageSession(message.info.sessionID)
+    ) {
+      return;
+    }
+
+    const reminder = state.backgroundJobBoard.formatForPrompt(
+      message.info.sessionID,
+    );
+    if (!reminder) return;
+
+    const textPart = message.parts.find(
+      (part) => part.type === 'text' && typeof part.text === 'string',
+    );
+    if (!textPart || isInternalInitiatorPart(textPart)) return;
+
+    rememberInjectedTerminalJobs(state, message.info.sessionID);
+    // Append the board as its own trailing message rather than mutating
+    // an existing user message. In long tool loops the latest user
+    // message becomes deep history; rewriting it on board state changes
+    // would invalidate the provider prompt cache for everything after
+    // it. A trailing message keeps board churn at the end of the
+    // prompt, where it only costs itself.
+    appendTrailingVolatileMessage(
+      messages,
+      {
+        ...message.info,
+        id: `${message.info.id}-background-job-board`,
+      },
+      {
+        text: reminder,
+        metadataKey: state.metadataKey,
+      },
+    );
+    return;
+  }
+}

+ 227 - 0
src/hooks/task-session-manager/continuation-evaluator.ts

@@ -0,0 +1,227 @@
+/**
+ * Continuation evaluator subsystem for task session manager.
+ *
+ * Evaluates whether a parent session needs a continuation nudge
+ * when its children complete but todos remain unfinished.
+ *
+ * Exported as a pure function taking explicit dependency objects
+ * to avoid circular dependency issues with the other subsystems.
+ */
+import { createInternalAgentTextPart } from '../../utils';
+import type { BackgroundJobStore } from '../../utils/background-job-store';
+import { isRecord as isObjectRecord } from '../../utils/guards';
+import { log } from '../../utils/logger';
+import { isActiveStatus } from './status-utils';
+
+const CONTINUATION_NUDGE =
+  'Continue coordinating the remaining incomplete todos. Do not finalize while work remains.';
+
+/**
+ * Shared 5-condition guard that appears (in identical form) after
+ * each async liveness re-check inside the main evaluation loop.
+ * Deduplicated here to avoid repeating the same short-circuit chain.
+ *
+ * Does NOT include the SDK-availability checks (first guard only);
+ * those remain inline since they only run once before any I/O.
+ */
+function isEvaluationAborted(
+  parentSessionID: string,
+  sessionToken: symbol,
+  evaluationToken: symbol,
+  deps: {
+    continuationTokens: {
+      consumed: Set<string>;
+      isCurrentContinuation: (
+        sessionID: string,
+        sessionToken: symbol,
+        evaluationToken?: symbol,
+      ) => boolean;
+    };
+    inputWaits: {
+      hasInputWait: (sessionID: string) => boolean;
+    };
+    options: {
+      isFallbackInProgress?: (sessionID: string) => boolean;
+    };
+    backgroundJobBoard: BackgroundJobStore;
+  },
+): boolean {
+  return (
+    deps.continuationTokens.consumed.has(parentSessionID) ||
+    deps.inputWaits.hasInputWait(parentSessionID) ||
+    !deps.continuationTokens.isCurrentContinuation(
+      parentSessionID,
+      sessionToken,
+      evaluationToken,
+    ) ||
+    deps.options.isFallbackInProgress?.(parentSessionID) ||
+    deps.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID)
+  );
+}
+
+export async function evaluateContinuation(
+  parentSessionID: string,
+  sessionToken: symbol,
+  deps: {
+    backgroundJobBoard: BackgroundJobStore;
+    continuationTokens: {
+      evaluations: Map<string, Set<symbol>>;
+      consumed: Set<string>;
+      isCurrentContinuation: (
+        sessionID: string,
+        sessionToken: symbol,
+        evaluationToken?: symbol,
+      ) => boolean;
+    };
+    inputWaits: {
+      hasInputWait: (sessionID: string) => boolean;
+    };
+    options: {
+      isFallbackInProgress?: (sessionID: string) => boolean;
+    };
+    sessionSdk?: {
+      todo?: (input: unknown) => Promise<{ data?: unknown }>;
+      children?: (input: unknown) => Promise<{ data?: unknown }>;
+      status?: (input: unknown) => Promise<{ data?: unknown }>;
+      promptAsync?: (input: unknown) => Promise<unknown>;
+    };
+  },
+): Promise<void> {
+  const evaluationToken = Symbol(parentSessionID);
+  const activeEvaluations =
+    deps.continuationTokens.evaluations.get(parentSessionID) ??
+    new Set<symbol>();
+  activeEvaluations.add(evaluationToken);
+  deps.continuationTokens.evaluations.set(parentSessionID, activeEvaluations);
+
+  // Guard 1: pre-flight checks (includes SDK availability — only once)
+  if (
+    deps.continuationTokens.consumed.has(parentSessionID) ||
+    deps.inputWaits.hasInputWait(parentSessionID) ||
+    !deps.continuationTokens.isCurrentContinuation(
+      parentSessionID,
+      sessionToken,
+      evaluationToken,
+    ) ||
+    deps.options.isFallbackInProgress?.(parentSessionID) ||
+    deps.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID) ||
+    !deps.sessionSdk?.todo ||
+    !deps.sessionSdk.children ||
+    !deps.sessionSdk.status ||
+    !deps.sessionSdk.promptAsync
+  ) {
+    activeEvaluations.delete(evaluationToken);
+    if (activeEvaluations.size === 0) {
+      deps.continuationTokens.evaluations.delete(parentSessionID);
+    }
+    return;
+  }
+
+  try {
+    const [todoResponse, childrenResponse, statusResponse] = await Promise.all([
+      deps.sessionSdk.todo({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.children({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.status({ throwOnError: true }),
+    ]);
+    if (
+      !Array.isArray(todoResponse.data) ||
+      !Array.isArray(childrenResponse.data) ||
+      !isObjectRecord(statusResponse.data)
+    ) {
+      return;
+    }
+    const todos = todoResponse.data;
+    const children = childrenResponse.data;
+    const status = statusResponse.data;
+    if (
+      !todos.every(
+        (todo) => isObjectRecord(todo) && typeof todo.status === 'string',
+      ) ||
+      !children.every(
+        (child) => isObjectRecord(child) && typeof child.id === 'string',
+      )
+    ) {
+      return;
+    }
+    if (
+      !todos.some(
+        (todo) => todo.status !== 'completed' && todo.status !== 'cancelled',
+      )
+    ) {
+      return;
+    }
+    const childIDs = children.map((child) => child.id as string);
+    if (
+      isActiveStatus(status, parentSessionID) ||
+      childIDs.some((childID) => isActiveStatus(status, childID))
+    ) {
+      return;
+    }
+
+    // Re-read liveness immediately before queuing work; board state is only
+    // authoritative for terminal results observed by this plugin instance.
+    const [latestChildrenResponse, latestStatusResponse] = await Promise.all([
+      deps.sessionSdk.children({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.status({ throwOnError: true }),
+    ]);
+    if (
+      !Array.isArray(latestChildrenResponse.data) ||
+      !isObjectRecord(latestStatusResponse.data) ||
+      !latestChildrenResponse.data.every(
+        (child) => isObjectRecord(child) && typeof child.id === 'string',
+      ) ||
+      isEvaluationAborted(parentSessionID, sessionToken, evaluationToken, deps)
+    ) {
+      return;
+    }
+    const latestChildIDs = latestChildrenResponse.data.map(
+      (child) => child.id as string,
+    );
+    const latestStatus = latestStatusResponse.data;
+    if (
+      isActiveStatus(latestStatus, parentSessionID) ||
+      latestChildIDs.some((childID) => isActiveStatus(latestStatus, childID))
+    ) {
+      return;
+    }
+
+    if (
+      isEvaluationAborted(parentSessionID, sessionToken, evaluationToken, deps)
+    ) {
+      return;
+    }
+    deps.continuationTokens.consumed.add(parentSessionID);
+    await deps.sessionSdk.promptAsync({
+      path: { id: parentSessionID },
+      body: {
+        agent: 'orchestrator',
+        parts: [createInternalAgentTextPart(CONTINUATION_NUDGE)],
+      },
+      throwOnError: true,
+    });
+  } catch (error) {
+    log(
+      '[task-session-manager] continuation nudge suppressed after SDK error',
+      {
+        parentSessionID,
+        error: error instanceof Error ? error.message : String(error),
+      },
+    );
+  } finally {
+    const evaluations =
+      deps.continuationTokens.evaluations.get(parentSessionID);
+    evaluations?.delete(evaluationToken);
+    if (evaluations?.size === 0) {
+      deps.continuationTokens.evaluations.delete(parentSessionID);
+    }
+  }
+}

+ 51 - 0
src/hooks/task-session-manager/continuation-token-manager.ts

@@ -0,0 +1,51 @@
+export function createContinuationTokenManager(options?: {
+  onInvalidateContinuation?: (sessionID: string) => void;
+}) {
+  const continuationSessionTokens = new Map<string, symbol>();
+  const activeContinuationEvaluations = new Map<string, Set<symbol>>();
+  const continuationConsumed = new Set<string>();
+
+  function getContinuationSessionToken(sessionID: string): symbol {
+    const existing = continuationSessionTokens.get(sessionID);
+    if (existing) return existing;
+
+    const token = Symbol(sessionID);
+    continuationSessionTokens.set(sessionID, token);
+    return token;
+  }
+
+  function isCurrentContinuation(
+    sessionID: string,
+    sessionToken: symbol,
+    evaluationToken?: symbol,
+  ): boolean {
+    return (
+      continuationSessionTokens.get(sessionID) === sessionToken &&
+      (evaluationToken === undefined ||
+        activeContinuationEvaluations.get(sessionID)?.has(evaluationToken) ===
+          true)
+    );
+  }
+
+  function invalidateContinuation(sessionID: string): void {
+    options?.onInvalidateContinuation?.(sessionID);
+    continuationSessionTokens.delete(sessionID);
+    activeContinuationEvaluations.delete(sessionID);
+  }
+
+  function clearContinuation(sessionID: string): void {
+    invalidateContinuation(sessionID);
+    continuationConsumed.delete(sessionID);
+  }
+
+  return {
+    getContinuationSessionToken,
+    isCurrentContinuation,
+    invalidateContinuation,
+    clearContinuation,
+    // Exposed internal state for consumers not yet migrated (evaluateContinuation, etc.)
+    sessionTokens: continuationSessionTokens,
+    evaluations: activeContinuationEvaluations,
+    consumed: continuationConsumed,
+  };
+}

+ 298 - 0
src/hooks/task-session-manager/event-router.ts

@@ -0,0 +1,298 @@
+/**
+ * Event router for task session manager.
+ *
+ * Routes lifecycle events (session.created, server.instance.disposed,
+ * session.idle, session.error, session.status, session.deleted) to
+ * the appropriate subsystems.
+ */
+import type { BackgroundJobStore } from '../../utils/background-job-store';
+import { log } from '../../utils/logger';
+import { isFailoverError } from '../foreground-fallback/index';
+import type { PendingTaskCall } from './pending-call-tracker';
+
+export async function handleEvent(
+  input: {
+    event: {
+      type: string;
+      properties?: {
+        info?: { id?: string; parentID?: string; agent?: string };
+        id?: string;
+        requestID?: string;
+        sessionID?: string;
+        status?: { type?: string };
+        error?: { name?: string };
+      };
+    };
+  },
+  deps: {
+    inputWaits: {
+      trackInputWait(event: {
+        type: string;
+        properties?: {
+          id?: string;
+          requestID?: string;
+          sessionID?: string;
+        };
+      }): void;
+      clearInputWaits(sessionID: string): void;
+      waitsByParent: Map<string, Set<string | symbol>>;
+    };
+    continuationTokens: {
+      clearContinuation(sessionID: string): void;
+      invalidateContinuation(sessionID: string): void;
+      sessionTokens: Map<string, symbol>;
+      evaluations: Map<string, Set<symbol>>;
+      consumed: Set<string>;
+    };
+    options: {
+      shouldManageSession: (sessionID: string) => boolean;
+      registerSessionAsOrchestrator?: (sessionID: string) => void;
+      isFallbackInProgress?: (sessionID: string) => boolean;
+    };
+    idleReconciler: {
+      scheduleIdleReconciliation(sessionID: string): void;
+      scheduleChildIdleReconciliation(
+        sessionID: string,
+        idleObservedAt: number,
+      ): void;
+      clearIdleTimers(sessionID: string): void;
+      clearAllTimers(): string[];
+    };
+    backgroundJobBoard: BackgroundJobStore;
+    pendingCallTracker: {
+      peekByParentAndAgent(
+        parentSessionID: string,
+        agentHint?: string,
+      ): PendingTaskCall | undefined;
+      clearSession(sessionID: string): void;
+    };
+    taskContextTracker: {
+      pendingManagedTaskIds: Set<string>;
+      clearSession(sessionID: string): void;
+      prune(board: { taskIDs(): Set<string> }): void;
+    };
+    terminalJobsInjectedByParent: Map<string, Set<string>>;
+  },
+): Promise<void> {
+  deps.inputWaits.trackInputWait(input.event);
+
+  if (input.event.type === 'session.created') {
+    const info = input.event.properties?.info;
+    log('[task-session-manager] session.created observed', {
+      sessionID: info?.id,
+      parentSessionID: info?.parentID,
+      managesParent: info?.parentID
+        ? deps.options.shouldManageSession(info.parentID)
+        : false,
+    });
+    if (
+      info?.id &&
+      info.parentID &&
+      deps.options.shouldManageSession(info.parentID)
+    ) {
+      deps.taskContextTracker.pendingManagedTaskIds.add(info.id);
+      // Early board registration: if the parent tool call is cancelled
+      // before tool.execute.after (e.g. foreground fallback abort), the
+      // after-hook never fires and the job is never tracked — idle then
+      // reports runningJobForSession:false and the orchestrator sees
+      // "Task cancelled" while the child is still working (#765).
+      // Peek (don't take) so tool.execute.after can still re-register.
+      //
+      // When the parent has multiple task calls in flight at once (e.g.
+      // parallel council reviewers), `info.agent` on the child session
+      // identifies which subagent started it; prefer the matching
+      // pending call so we don't attribute the child to the wrong agent.
+      const pending = deps.pendingCallTracker.peekByParentAndAgent(
+        info.parentID,
+        info.agent,
+      );
+      if (
+        pending &&
+        !pending.resumedTaskId &&
+        !deps.backgroundJobBoard.get(info.id)
+      ) {
+        const record = deps.backgroundJobBoard.registerLaunch({
+          taskID: info.id,
+          parentSessionID: pending.parentSessionId,
+          agent: pending.agentType,
+          description: pending.label,
+          objective: pending.label,
+        });
+        log(
+          '[task-session-manager] early board registration from session.created',
+          {
+            taskID: record.taskID,
+            alias: record.alias,
+            parentSessionID: record.parentSessionID,
+            agent: record.agent,
+          },
+        );
+      }
+    }
+    return;
+  }
+
+  if (input.event.type === 'server.instance.disposed') {
+    const idleSessionIds = deps.idleReconciler.clearAllTimers();
+    const continuationSessionIDs = new Set([
+      ...idleSessionIds,
+      ...deps.continuationTokens.sessionTokens.keys(),
+      ...deps.continuationTokens.evaluations.keys(),
+      ...deps.continuationTokens.consumed,
+      ...deps.inputWaits.waitsByParent.keys(),
+    ]);
+    for (const sessionID of continuationSessionIDs) {
+      deps.continuationTokens.clearContinuation(sessionID);
+      deps.inputWaits.clearInputWaits(sessionID);
+    }
+    return;
+  }
+
+  if (
+    input.event.type === 'session.idle' ||
+    (input.event.type === 'session.status' &&
+      (input.event.properties as { status?: { type?: string } } | undefined)
+        ?.status?.type === 'idle')
+  ) {
+    const sessionId =
+      input.event.properties?.info?.id || input.event.properties?.sessionID;
+    const job = sessionId ? deps.backgroundJobBoard.get(sessionId) : undefined;
+    log('[task-session-manager] idle/status idle observed', {
+      sessionID: sessionId,
+      managesSession: sessionId
+        ? deps.options.shouldManageSession(sessionId)
+        : false,
+      terminalJobsPending: sessionId
+        ? (deps.terminalJobsInjectedByParent.get(sessionId)?.size ?? 0)
+        : 0,
+      runningJobForSession: job?.state === 'running' || false,
+    });
+    if (sessionId && deps.options.shouldManageSession(sessionId)) {
+      deps.idleReconciler.scheduleIdleReconciliation(sessionId);
+    }
+
+    // Fallback: for background child sessions that go idle without
+    // an injected completion, reconcile the board entry since the
+    // session being idle is itself the completion signal.
+    // Delayed so FG can claim the session before we mark completed.
+    if (job && sessionId && job.state === 'running') {
+      deps.idleReconciler.scheduleChildIdleReconciliation(
+        sessionId,
+        Date.now(),
+      );
+    }
+    return;
+  }
+
+  if (input.event.type === 'session.error') {
+    const sessionId =
+      input.event.properties?.info?.id || input.event.properties?.sessionID;
+    if (sessionId) {
+      deps.continuationTokens.invalidateContinuation(sessionId);
+    }
+    if (sessionId && deps.options.shouldManageSession(sessionId)) {
+      // Only clear injected terminal jobs for fatal errors.
+      // Rate-limit errors are recovered by ForegroundFallbackManager
+      // (abort + reprompt with fallback model); clearing the injected
+      // job state here would make the orchestrator lose track of
+      // completed background tasks and unable to dispatch follow-ups.
+      const props = input.event.properties as { error?: unknown } | undefined;
+      if (!props?.error || !isFailoverError(props.error)) {
+        deps.terminalJobsInjectedByParent.delete(sessionId);
+        // Record non-retryable errors on the job board so the
+        // orchestrator sees the failure instead of a false completion.
+        const job = deps.backgroundJobBoard.get(sessionId);
+        if (job && job.state === 'running') {
+          deps.backgroundJobBoard.updateStatus({
+            taskID: sessionId,
+            state: 'error',
+            resultSummary:
+              (props?.error as { message?: string } | undefined)?.message ??
+              'Session error',
+          });
+        }
+      }
+    } else if (sessionId) {
+      // Child subagent sessions are not orchestrators, so the block
+      // above never runs for them. Without this, a failed background
+      // subagent leaves its job in `running` and the idle-reconciliation
+      // path (which has no shouldManageSession guard) marks it
+      // `completed` — a false success. A child with no fallback chain has
+      // nothing to retry into, so surface the failure on the board.
+      const props = input.event.properties as { error?: unknown } | undefined;
+      if (deps.options.isFallbackInProgress?.(sessionId)) return;
+      const job = deps.backgroundJobBoard.get(sessionId);
+      if (job && job.state === 'running') {
+        deps.backgroundJobBoard.updateStatus({
+          taskID: sessionId,
+          state: 'error',
+          resultSummary:
+            (props?.error as { message?: string } | undefined)?.message ??
+            'Session error',
+        });
+      }
+    }
+
+    return;
+  }
+
+  if (input.event.type === 'session.status') {
+    const sessionId =
+      input.event.properties?.info?.id || input.event.properties?.sessionID;
+    const statusType = (
+      input.event.properties as { status?: { type?: string } } | undefined
+    )?.status?.type;
+    if (sessionId) deps.continuationTokens.invalidateContinuation(sessionId);
+    if (statusType !== 'busy') {
+      return;
+    }
+    // Live busy cancels a pending child idle-reconcile — the session
+    // recovered (FG re-prompt or continued work).
+    // Note: invalidateContinuation above already cleared the parent
+    // idle-reconcile timer; clearIdleTimers handles the child timer.
+    if (sessionId) {
+      deps.idleReconciler.clearIdleTimers(sessionId);
+    }
+    const before = sessionId
+      ? deps.backgroundJobBoard.get(sessionId)
+      : undefined;
+    const updated = sessionId
+      ? deps.backgroundJobBoard.markRunningFromLiveSession(sessionId)
+      : undefined;
+    if (before?.cancellationRequested) {
+      log('[task-session-manager] busy observed after cancel request', {
+        sessionID: sessionId,
+        previousState: before.state,
+        previousTerminalState: before.terminalState,
+        terminalUnreconciled: before.terminalUnreconciled,
+        resultSummary: before.resultSummary,
+      });
+    }
+    log('[task-session-manager] busy/status busy observed', {
+      sessionID: sessionId,
+      managesSession: sessionId
+        ? deps.options.shouldManageSession(sessionId)
+        : false,
+      previousState: before?.state,
+      previousTerminalState: before?.terminalState,
+      previousCancellationRequested: before?.cancellationRequested ?? false,
+      previousLastLiveBusyAt: before?.lastLiveBusyAt,
+      updatedState: updated?.state,
+      updatedCancellationRequested: updated?.cancellationRequested ?? false,
+      updatedLastLiveBusyAt: updated?.lastLiveBusyAt,
+    });
+    return;
+  }
+
+  if (input.event.type !== 'session.deleted') return;
+  const sessionId =
+    input.event.properties?.info?.id || input.event.properties?.sessionID;
+  if (!sessionId) return;
+
+  deps.continuationTokens.clearContinuation(sessionId);
+  deps.inputWaits.clearInputWaits(sessionId);
+
+  log('[task-session-manager] session.deleted observed', {
+    sessionID: sessionId,
+  });
+}

+ 145 - 0
src/hooks/task-session-manager/idle-reconciliation.ts

@@ -0,0 +1,145 @@
+import type { BackgroundJobStore, ContextFile } from '../../utils';
+import { log } from '../../utils/logger';
+
+export function createIdleReconciler(options: {
+  backgroundJobBoard: BackgroundJobStore;
+  evaluateContinuation: (
+    parentSessionID: string,
+    sessionToken: symbol,
+  ) => Promise<void>;
+  reconcileInjectedTerminalJobs: (parentSessionID: string) => void;
+  idleReconcileDelayMs: number;
+  isFallbackInProgress?: (sessionID: string) => boolean;
+  hasInputWait: (sessionID: string) => boolean;
+  getContinuationSessionToken: (sessionID: string) => symbol;
+  isCurrentContinuation: (
+    sessionID: string,
+    sessionToken: symbol,
+    evaluationToken?: symbol,
+  ) => boolean;
+  taskContextTracker: {
+    pendingManagedTaskIds: Set<string>;
+    contextFilesForPrompt(taskId: string): ContextFile[];
+    prune(board: { taskIDs(): Set<string> }): void;
+  };
+}) {
+  const idleReconcileTimers = new Map<string, ReturnType<typeof setTimeout>>();
+  const childIdleReconcileTimers = new Map<
+    string,
+    ReturnType<typeof setTimeout>
+  >();
+
+  function scheduleIdleReconciliation(parentSessionID: string): void {
+    if (
+      idleReconcileTimers.has(parentSessionID) ||
+      options.hasInputWait(parentSessionID) ||
+      options.isFallbackInProgress?.(parentSessionID)
+    ) {
+      return;
+    }
+    const sessionToken = options.getContinuationSessionToken(parentSessionID);
+    const timer = setTimeout(() => {
+      idleReconcileTimers.delete(parentSessionID);
+      if (!options.isCurrentContinuation(parentSessionID, sessionToken)) {
+        return;
+      }
+      const hadTerminalUnreconciled =
+        options.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID);
+      options.reconcileInjectedTerminalJobs(parentSessionID);
+      if (!hadTerminalUnreconciled) {
+        void options.evaluateContinuation(parentSessionID, sessionToken);
+      }
+    }, options.idleReconcileDelayMs).unref?.();
+    idleReconcileTimers.set(parentSessionID, timer);
+  }
+
+  function scheduleChildIdleReconciliation(
+    sessionID: string,
+    idleObservedAt: number,
+  ): void {
+    if (childIdleReconcileTimers.has(sessionID)) return;
+    if (options.isFallbackInProgress?.(sessionID)) return;
+
+    const timer = setTimeout(() => {
+      childIdleReconcileTimers.delete(sessionID);
+      if (options.isFallbackInProgress?.(sessionID)) return;
+
+      const job = options.backgroundJobBoard.get(sessionID);
+      if (!job || job.state !== 'running') return;
+
+      // Busy after the idle means the session recovered (e.g. FG re-prompt).
+      if (
+        job.lastLiveBusyAt !== undefined &&
+        job.lastLiveBusyAt > idleObservedAt
+      ) {
+        return;
+      }
+
+      log('[task-session-manager] reconciled running job from idle', {
+        sessionID,
+        alias: job.alias,
+        parentSessionID: job.parentSessionID,
+      });
+      options.backgroundJobBoard.updateStatus({
+        taskID: sessionID,
+        state: 'completed',
+        resultSummary: 'Background task completed (reconciled from idle event)',
+      });
+      options.backgroundJobBoard.markReconciled(sessionID);
+      options.taskContextTracker.pendingManagedTaskIds.delete(sessionID);
+      options.backgroundJobBoard.addContext(
+        sessionID,
+        options.taskContextTracker.contextFilesForPrompt(sessionID),
+      );
+      options.taskContextTracker.prune(options.backgroundJobBoard);
+    }, options.idleReconcileDelayMs).unref?.();
+    childIdleReconcileTimers.set(sessionID, timer);
+  }
+
+  function clearIdleTimers(sessionID: string): void {
+    const pendingChildIdle = childIdleReconcileTimers.get(sessionID);
+    if (pendingChildIdle) {
+      clearTimeout(pendingChildIdle);
+      childIdleReconcileTimers.delete(sessionID);
+    }
+    const pendingIdle = idleReconcileTimers.get(sessionID);
+    if (pendingIdle) {
+      clearTimeout(pendingIdle);
+      idleReconcileTimers.delete(sessionID);
+    }
+  }
+
+  /**
+   * Clears all timers and returns the session IDs that had
+   * idle-reconcile timers (used by server.instance.disposed).
+   */
+  function clearAllTimers(): string[] {
+    for (const timer of childIdleReconcileTimers.values()) {
+      clearTimeout(timer);
+    }
+    childIdleReconcileTimers.clear();
+
+    const idleSessionIds = [...idleReconcileTimers.keys()];
+    for (const timer of idleReconcileTimers.values()) {
+      clearTimeout(timer);
+    }
+    idleReconcileTimers.clear();
+
+    return idleSessionIds;
+  }
+
+  return {
+    scheduleIdleReconciliation,
+    scheduleChildIdleReconciliation,
+    clearIdleTimers,
+    clearAllTimers,
+    /** Callback for continuation-token-manager's onInvalidateContinuation. */
+    onInvalidateContinuation: (sessionID: string) => {
+      const timer = idleReconcileTimers.get(sessionID);
+      if (timer) {
+        clearTimeout(timer);
+        idleReconcileTimers.delete(sessionID);
+      }
+    },
+  };
+}

+ 21 - 6
src/hooks/task-session-manager/index.test.ts

@@ -2753,25 +2753,40 @@ describe('task-session-manager hook', () => {
     await hook.event({
       event: {
         type: 'session.created',
-        properties: { info: { id: 'child-a', parentID: 'parent-1', agent: 'oracle' } },
+        properties: {
+          info: { id: 'child-a', parentID: 'parent-1', agent: 'oracle' },
+        },
       },
     });
     await hook.event({
       event: {
         type: 'session.created',
-        properties: { info: { id: 'child-b', parentID: 'parent-1', agent: 'explorer' } },
+        properties: {
+          info: { id: 'child-b', parentID: 'parent-1', agent: 'explorer' },
+        },
       },
     });
     await hook.event({
       event: {
         type: 'session.created',
-        properties: { info: { id: 'child-c', parentID: 'parent-1', agent: 'fixer' } },
+        properties: {
+          info: { id: 'child-c', parentID: 'parent-1', agent: 'fixer' },
+        },
       },
     });
 
-    expect(board.get('child-a')).toMatchObject({ agent: 'oracle', description: 'audit loss' });
-    expect(board.get('child-b')).toMatchObject({ agent: 'explorer', description: 'audit data' });
-    expect(board.get('child-c')).toMatchObject({ agent: 'fixer', description: 'audit fix' });
+    expect(board.get('child-a')).toMatchObject({
+      agent: 'oracle',
+      description: 'audit loss',
+    });
+    expect(board.get('child-b')).toMatchObject({
+      agent: 'explorer',
+      description: 'audit data',
+    });
+    expect(board.get('child-c')).toMatchObject({
+      agent: 'fixer',
+      description: 'audit fix',
+    });
   });
 
   test('cancelled job is not reconciled from idle', async () => {

File diff suppressed because it is too large
+ 104 - 903
src/hooks/task-session-manager/index.ts


+ 88 - 0
src/hooks/task-session-manager/input-wait-tracker.ts

@@ -0,0 +1,88 @@
+const IDLESS_INPUT_WAIT = Symbol('idless-input-wait');
+const INPUT_WAIT_ASK_EVENTS = {
+  'permission.asked': 'permission',
+  'question.asked': 'question',
+} as const;
+const INPUT_WAIT_RESOLUTION_EVENTS = {
+  'permission.replied': 'permission',
+  'question.replied': 'question',
+  'question.rejected': 'question',
+} as const;
+
+function isInputWaitAskEvent(
+  type: string,
+): type is keyof typeof INPUT_WAIT_ASK_EVENTS {
+  return Object.hasOwn(INPUT_WAIT_ASK_EVENTS, type);
+}
+
+function isInputWaitResolutionEvent(
+  type: string,
+): type is keyof typeof INPUT_WAIT_RESOLUTION_EVENTS {
+  return Object.hasOwn(INPUT_WAIT_RESOLUTION_EVENTS, type);
+}
+
+function inputWaitKey(kind: 'permission' | 'question', requestID: string) {
+  return `${kind}:${requestID}`;
+}
+
+export function createInputWaitTracker(options: {
+  shouldManageSession: (sessionID: string) => boolean;
+  invalidateContinuation: (sessionID: string) => void;
+}) {
+  const inputWaitsByParent = new Map<string, Set<string | symbol>>();
+
+  function hasInputWait(sessionID: string): boolean {
+    return (inputWaitsByParent.get(sessionID)?.size ?? 0) > 0;
+  }
+
+  function clearInputWaits(sessionID: string): void {
+    inputWaitsByParent.delete(sessionID);
+  }
+
+  function trackInputWait(event: {
+    type: string;
+    properties?: { id?: string; requestID?: string; sessionID?: string };
+  }): void {
+    const sessionID = event.properties?.sessionID;
+    if (!sessionID || !options.shouldManageSession(sessionID)) {
+      return;
+    }
+
+    if (isInputWaitAskEvent(event.type)) {
+      const requestID = event.properties?.id;
+      const waits =
+        inputWaitsByParent.get(sessionID) ?? new Set<string | symbol>();
+      if (!requestID) {
+        waits.add(IDLESS_INPUT_WAIT);
+        inputWaitsByParent.set(sessionID, waits);
+        options.invalidateContinuation(sessionID);
+        return;
+      }
+      const key = inputWaitKey(INPUT_WAIT_ASK_EVENTS[event.type], requestID);
+      waits.add(key);
+      inputWaitsByParent.set(sessionID, waits);
+      options.invalidateContinuation(sessionID);
+      return;
+    }
+
+    if (!isInputWaitResolutionEvent(event.type)) return;
+    const requestID = event.properties?.requestID;
+    if (!requestID) return;
+    const key = inputWaitKey(
+      INPUT_WAIT_RESOLUTION_EVENTS[event.type],
+      requestID,
+    );
+    const waits = inputWaitsByParent.get(sessionID);
+    if (!waits) return;
+    waits.delete(key);
+    if (waits.size === 0) clearInputWaits(sessionID);
+  }
+
+  return {
+    trackInputWait,
+    hasInputWait,
+    clearInputWaits,
+    // Exposed for consumers not yet migrated (disposed handler, etc.)
+    waitsByParent: inputWaitsByParent,
+  };
+}

+ 1 - 4
src/hooks/task-session-manager/pending-call-tracker.ts

@@ -57,10 +57,7 @@ export function createPendingCallTracker() {
      * Falls back to the oldest pending call for the parent when no
      * agent match is found (preserves prior behavior).
      */
-    peekByParentAndAgent(
-      parentSessionId: string,
-      agentHint?: string,
-    ) {
+    peekByParentAndAgent(parentSessionId: string, agentHint?: string) {
       if (!agentHint) return this.peekByParent(parentSessionId);
       let fallback: PendingTaskCall | undefined;
       for (const call of pendingCalls.values()) {

+ 137 - 0
src/hooks/task-session-manager/status-utils.ts

@@ -0,0 +1,137 @@
+import type {
+  BackgroundJobRecord,
+  BackgroundJobStore,
+  ContextFile,
+} from '../../utils';
+import { parseTaskStatusOutput } from '../../utils';
+import { isRecord as isObjectRecord } from '../../utils/guards';
+import { log } from '../../utils/logger';
+
+export function extractTaskSummary(output: string): string | undefined {
+  const summary = /<summary>\s*([\s\S]*?)\s*<\/summary>/i.exec(output)?.[1];
+  return summary?.trim() || undefined;
+}
+
+export function isActiveStatus(
+  status: Record<string, unknown>,
+  sessionID: string,
+): boolean {
+  return Object.hasOwn(status, sessionID);
+}
+
+export function isLateCancelledTaskError(
+  job: BackgroundJobRecord | undefined,
+  state: string,
+): boolean {
+  if (state !== 'error') return false;
+  if (!job?.cancellationRequested) return false;
+  return job.state === 'cancelled' || job.terminalState === 'cancelled';
+}
+
+export function formatCancelledTaskStatusOutput(
+  taskID: string,
+  summary = 'cancelled',
+): string {
+  return [
+    `task_id: ${taskID}`,
+    'state: cancelled',
+    '',
+    '<task_error>',
+    summary,
+    '</task_error>',
+  ].join('\n');
+}
+
+export function updateBackgroundJobFromOutput(
+  output: unknown,
+  backgroundJobBoard: BackgroundJobStore,
+  taskContextTracker: {
+    pendingManagedTaskIds: Set<string>;
+    contextFilesForPrompt(taskId: string): ContextFile[];
+    prune(board: { taskIDs(): Set<string> }): void;
+  },
+): BackgroundJobRecord | undefined {
+  if (typeof output !== 'string') return undefined;
+
+  const status = parseTaskStatusOutput(output);
+  if (!status) return undefined;
+
+  log('[task-session-manager] parsed task output status', {
+    taskID: status.taskID,
+    state: status.state,
+    timedOut: status.timedOut,
+    hasResult: Boolean(status.result),
+  });
+
+  const existing = backgroundJobBoard.get(status.taskID);
+  if (isLateCancelledTaskError(existing, status.state)) {
+    log('[task-session-manager] suppressed late cancelled task error', {
+      taskID: status.taskID,
+      alias: existing?.alias,
+      parsedState: status.state,
+      boardState: existing?.state,
+      terminalState: existing?.terminalState,
+      result: status.result,
+    });
+    return existing;
+  }
+
+  const updated = backgroundJobBoard.updateStatus({
+    taskID: status.taskID,
+    state: status.state,
+    timedOut: status.timedOut,
+    resultSummary: status.result,
+  });
+  if (!updated) {
+    log('[task-session-manager] ignored status for unknown background job', {
+      taskID: status.taskID,
+      state: status.state,
+    });
+    return undefined;
+  }
+
+  log('[task-session-manager] background job status updated', {
+    taskID: updated.taskID,
+    alias: updated.alias,
+    parentSessionID: updated.parentSessionID,
+    state: updated.state,
+    terminalUnreconciled: updated.terminalUnreconciled,
+    timedOut: updated.timedOut,
+  });
+
+  if (backgroundJobBoard.isTerminalUnreconciled(updated.taskID)) {
+    taskContextTracker.pendingManagedTaskIds.delete(updated.taskID);
+    backgroundJobBoard.addContext(
+      updated.taskID,
+      taskContextTracker.contextFilesForPrompt(updated.taskID),
+    );
+    taskContextTracker.prune(backgroundJobBoard);
+  }
+
+  return updated;
+}
+
+export function normalizeLateCancelledTaskOutput(
+  output: { output: unknown; metadata?: unknown },
+  backgroundJobBoard: BackgroundJobStore,
+): void {
+  if (typeof output.output !== 'string') return;
+  const status = parseTaskStatusOutput(output.output);
+  if (!status) return;
+  const existing = backgroundJobBoard.get(status.taskID);
+  if (!isLateCancelledTaskError(existing, status.state)) return;
+  log('[task-session-manager] normalized late cancelled task output', {
+    taskID: status.taskID,
+    alias: existing?.alias,
+    state: existing?.state,
+    terminalState: existing?.terminalState,
+    result: status.result,
+  });
+  output.output = formatCancelledTaskStatusOutput(
+    status.taskID,
+    backgroundJobBoard.getResultSummary(status.taskID),
+  );
+  if (isObjectRecord(output) && isObjectRecord(output.metadata)) {
+    output.metadata.state = 'cancelled';
+  }
+}

+ 277 - 0
src/hooks/task-session-manager/tool-execute-hooks.ts

@@ -0,0 +1,277 @@
+/**
+ * Tool execute hooks for task session manager.
+ *
+ * Handles `tool.execute.before` (task tool: pending call creation,
+ * reusable/recoverable task_id resolution) and `tool.execute.after`
+ * (read context tracking, task launch registration/update from output).
+ */
+import type { BackgroundJobStore, ContextFile } from '../../utils';
+import {
+  deriveTaskSessionLabel,
+  parseTaskIdFromTaskOutput,
+  parseTaskLaunchOutput,
+  parseTaskStatusOutput,
+} from '../../utils';
+import { isRecord as isObjectRecord } from '../../utils/guards';
+import { log } from '../../utils/logger';
+import { isMissingRememberedSessionError } from './board-injection';
+import type { PendingTaskCall } from './pending-call-tracker';
+import { normalizeLateCancelledTaskOutput } from './status-utils';
+import { extractReadFiles } from './task-context-tracker';
+
+const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
+
+interface TaskArgs {
+  description?: unknown;
+  prompt?: unknown;
+  subagent_type?: unknown;
+  task_id?: unknown;
+}
+
+export async function handleToolExecuteBefore(
+  input: { tool: string; sessionID?: string; callID?: string },
+  output: { args?: unknown },
+  deps: {
+    shouldManageSession: (sessionID: string) => boolean;
+    registerSessionAsOrchestrator?: (sessionID: string) => void;
+    backgroundJobBoard: BackgroundJobStore;
+    pendingCallTracker: {
+      add(call: PendingTaskCall): void;
+      pendingCallId(sessionID?: string, callID?: string): string;
+    };
+    taskContextTracker: { pendingManagedTaskIds: Set<string> };
+  },
+): Promise<void> {
+  const toolName = input.tool.toLowerCase();
+  if (toolName !== 'task') return;
+  if (!input.sessionID) return;
+  if (!deps.shouldManageSession(input.sessionID)) {
+    // ponytail: no agent-identity guard here — at tool.execute.before
+    // time there's no message to inspect. Only orchestrators call `task`
+    // in standard architecture; non-orchestrator false-positives are
+    // accepted because leaf agents don't use this tool.
+    deps.registerSessionAsOrchestrator?.(input.sessionID);
+    if (!deps.shouldManageSession(input.sessionID)) return;
+    log('[task-session-manager] recovered stale orchestrator mapping', {
+      sessionID: input.sessionID,
+    });
+  }
+  if (!isObjectRecord(output.args)) return;
+
+  const args = output.args as TaskArgs;
+  if (
+    typeof args.subagent_type !== 'string' ||
+    args.subagent_type.trim() === ''
+  ) {
+    if (typeof args.task_id === 'string' && args.task_id.trim() !== '') {
+      delete args.task_id;
+    }
+    return;
+  }
+
+  const agentType = args.subagent_type.trim();
+
+  const label = deriveTaskSessionLabel({
+    description:
+      typeof args.description === 'string' ? args.description : undefined,
+    prompt: typeof args.prompt === 'string' ? args.prompt : undefined,
+    agentType,
+  });
+
+  const pendingCall: PendingTaskCall = {
+    callId: deps.pendingCallTracker.pendingCallId(
+      input.sessionID,
+      input.callID,
+    ),
+    parentSessionId: input.sessionID,
+    agentType,
+    label,
+  };
+  deps.pendingCallTracker.add(pendingCall);
+  log(
+    '[task-session-manager] tool.execute.before task — pending call created',
+    {
+      callId: pendingCall.callId,
+      parentSessionId: pendingCall.parentSessionId,
+      agentType: pendingCall.agentType,
+      label: pendingCall.label,
+      inputCallID: input.callID,
+      inputSessionID: input.sessionID,
+    },
+  );
+
+  if (typeof args.task_id !== 'string' || args.task_id.trim() === '') {
+    return;
+  }
+
+  const requested = args.task_id.trim();
+  const remembered =
+    deps.backgroundJobBoard.resolveReusable(
+      input.sessionID,
+      requested,
+      agentType,
+    ) ??
+    deps.backgroundJobBoard.resolveRecoverable(
+      input.sessionID,
+      requested,
+      agentType,
+    );
+
+  if (!remembered) {
+    const knownManagedTask = deps.backgroundJobBoard.resolve(
+      input.sessionID,
+      requested,
+    );
+    if (knownManagedTask) {
+      delete args.task_id;
+      return;
+    }
+
+    if (RAW_SESSION_ID_PATTERN.test(requested)) {
+      pendingCall.resumedTaskId = requested;
+      deps.pendingCallTracker.add(pendingCall);
+      return;
+    }
+    delete args.task_id;
+    return;
+  }
+
+  args.task_id = remembered.taskID;
+  deps.taskContextTracker.pendingManagedTaskIds.add(remembered.taskID);
+  deps.backgroundJobBoard.markUsed(input.sessionID, remembered.taskID);
+  pendingCall.resumedTaskId = remembered.taskID;
+  deps.pendingCallTracker.add(pendingCall);
+}
+
+export async function handleToolExecuteAfter(
+  input: { tool: string; sessionID?: string; callID?: string },
+  output: { output: unknown; metadata?: unknown },
+  deps: {
+    directory: string;
+    backgroundJobBoard: BackgroundJobStore;
+    pendingCallTracker: {
+      take(callID?: string, sessionID?: string): PendingTaskCall | undefined;
+    };
+    taskContextTracker: {
+      pendingManagedTaskIds: Set<string>;
+      addContext(taskId: string, files: ContextFile[]): void;
+      contextFilesForPrompt(taskId: string): ContextFile[];
+      prune(board: { taskIDs(): Set<string> }): void;
+    };
+  },
+): Promise<void> {
+  if (input.tool.toLowerCase() === 'read') {
+    if (input.sessionID) {
+      const canTrack =
+        deps.taskContextTracker.pendingManagedTaskIds.has(input.sessionID) ||
+        deps.backgroundJobBoard.taskIDs().has(input.sessionID);
+      if (canTrack) {
+        deps.taskContextTracker.addContext(
+          input.sessionID,
+          extractReadFiles(deps.directory, output),
+        );
+      }
+    }
+    return;
+  }
+
+  if (input.tool.toLowerCase() !== 'task') return;
+
+  const pending = deps.pendingCallTracker.take(input.callID, input.sessionID);
+  log('[task-session-manager] tool.execute.after task', {
+    callID: input.callID,
+    sessionID: input.sessionID,
+    hasPending: !!pending,
+    outputType: typeof output.output,
+    outputPreview:
+      typeof output.output === 'string'
+        ? output.output.slice(0, 120)
+        : undefined,
+  });
+
+  if (!pending || typeof output.output !== 'string') return;
+  const launch = parseTaskLaunchOutput(output.output);
+  if (launch && !launch.result?.match(/Timed out after \d+ms/i)) {
+    const record = deps.backgroundJobBoard.registerLaunch({
+      taskID: launch.taskID,
+      parentSessionID: pending.parentSessionId,
+      agent: pending.agentType,
+      description: pending.label,
+      objective: pending.label,
+    });
+    log('[task-session-manager] background task launch registered', {
+      taskID: record.taskID,
+      alias: record.alias,
+      parentSessionID: record.parentSessionID,
+      agent: record.agent,
+      description: record.description,
+      state: record.state,
+    });
+    deps.taskContextTracker.pendingManagedTaskIds.add(launch.taskID);
+    deps.backgroundJobBoard.addContext(
+      launch.taskID,
+      deps.taskContextTracker.contextFilesForPrompt(launch.taskID),
+    );
+    return;
+  }
+
+  normalizeLateCancelledTaskOutput(output, deps.backgroundJobBoard);
+  const status = parseTaskStatusOutput(output.output);
+  if (status) {
+    const existing = deps.backgroundJobBoard.get(status.taskID);
+    const record =
+      existing ??
+      deps.backgroundJobBoard.registerLaunch({
+        taskID: status.taskID,
+        parentSessionID: pending.parentSessionId,
+        agent: pending.agentType,
+        description: pending.label,
+        objective: pending.label,
+      });
+    const updated = deps.backgroundJobBoard.updateStatus({
+      taskID: status.taskID,
+      state: status.state,
+      timedOut: status.timedOut,
+      resultSummary: status.result,
+    });
+    log('[task-session-manager] foreground task status registered', {
+      taskID: status.taskID,
+      alias: updated?.alias ?? record.alias,
+      parentSessionID: pending.parentSessionId,
+      agent: pending.agentType,
+      state: updated?.state ?? record.state,
+    });
+    if (pending.resumedTaskId && pending.resumedTaskId !== status.taskID) {
+      deps.backgroundJobBoard.drop(pending.resumedTaskId);
+    }
+    deps.taskContextTracker.pendingManagedTaskIds.delete(status.taskID);
+    deps.backgroundJobBoard.addContext(
+      status.taskID,
+      deps.taskContextTracker.contextFilesForPrompt(status.taskID),
+    );
+    deps.taskContextTracker.prune(deps.backgroundJobBoard);
+    return;
+  }
+
+  const taskId = parseTaskIdFromTaskOutput(output.output);
+  if (!taskId) {
+    if (
+      pending.resumedTaskId &&
+      isMissingRememberedSessionError(output.output)
+    ) {
+      deps.backgroundJobBoard.drop(pending.resumedTaskId);
+    }
+    return;
+  }
+
+  if (pending.resumedTaskId && pending.resumedTaskId !== taskId) {
+    deps.backgroundJobBoard.drop(pending.resumedTaskId);
+  }
+
+  deps.taskContextTracker.pendingManagedTaskIds.delete(taskId);
+  deps.backgroundJobBoard.addContext(
+    taskId,
+    deps.taskContextTracker.contextFilesForPrompt(taskId),
+  );
+  deps.taskContextTracker.prune(deps.backgroundJobBoard);
+}

Some files were not shown because too many files changed in this diff