Browse Source

fix: preserve terminal execution identity during reconciliation

Alvin Unreal 2 weeks ago
parent
commit
86f4ac6d0d

+ 88 - 38
src/hooks/task-session-manager/board-injection.ts

@@ -9,6 +9,7 @@
  */
  */
 import { createHash } from 'node:crypto';
 import { createHash } from 'node:crypto';
 import type {
 import type {
+  BackgroundJobExecution,
   BackgroundJobRecord,
   BackgroundJobRecord,
   BackgroundJobStore,
   BackgroundJobStore,
   ContextFile,
   ContextFile,
@@ -49,7 +50,7 @@ type RetainedBoardSnapshot = {
   anchorKey: string;
   anchorKey: string;
   id: string;
   id: string;
   text: string;
   text: string;
-  terminalUnreconciledTaskIDs: string[];
+  terminalUnreconciledTaskIDs: BackgroundJobExecution[];
 };
 };
 
 
 export type RetainedBoardSnapshotState = {
 export type RetainedBoardSnapshotState = {
@@ -62,11 +63,8 @@ export type RetainedBoardSnapshotState = {
 // ── State shape ────────────────────────────────────────────────────────
 // ── State shape ────────────────────────────────────────────────────────
 
 
 export type InjectedTerminalJobs = {
 export type InjectedTerminalJobs = {
-  taskIDs: Set<string>;
-  /**
-   * Prompt shape when these task IDs were last surfaced to the model.
-   * Empty when a synthetic completion was processed before board injection.
-   */
+  executions: Map<string, BackgroundJobExecution>;
+  /** Prompt shape when these executions were last surfaced to the model. */
   promptShapeKey: string;
   promptShapeKey: string;
 };
 };
 
 
@@ -77,6 +75,10 @@ export interface InjectionState {
   processedInjectedCompletions: Set<string>;
   processedInjectedCompletions: Set<string>;
   processedInjectedCompletionOrder: string[];
   processedInjectedCompletionOrder: string[];
   terminalJobsInjectedByParent: Map<string, InjectedTerminalJobs>;
   terminalJobsInjectedByParent: Map<string, InjectedTerminalJobs>;
+  pendingInjectedTerminalJobsByParent: Map<
+    string,
+    Map<string, BackgroundJobExecution>
+  >;
   maxProcessedInjectedCompletions: number;
   maxProcessedInjectedCompletions: number;
   metadataKey: string;
   metadataKey: string;
   shouldManageSession: (sessionID: string) => boolean;
   shouldManageSession: (sessionID: string) => boolean;
@@ -229,9 +231,10 @@ export function updateFromInjectedCompletion(
     });
     });
     rememberProcessedInjectedCompletion(state, occurrenceId);
     rememberProcessedInjectedCompletion(state, occurrenceId);
     if (existing?.terminalUnreconciled && existing?.parentSessionID) {
     if (existing?.terminalUnreconciled && existing?.parentSessionID) {
-      rememberInjectedTerminalJobs(state, existing.parentSessionID, [
-        existing.taskID,
-      ]);
+      rememberPendingInjectedTerminalJob(state, existing.parentSessionID, {
+        taskID: existing.taskID,
+        generation: existing.generation,
+      });
     }
     }
     return existing;
     return existing;
   }
   }
@@ -249,9 +252,10 @@ export function updateFromInjectedCompletion(
   if (!updated) return undefined;
   if (!updated) return undefined;
 
 
   if (updated.terminalUnreconciled && updated.parentSessionID) {
   if (updated.terminalUnreconciled && updated.parentSessionID) {
-    rememberInjectedTerminalJobs(state, updated.parentSessionID, [
-      updated.taskID,
-    ]);
+    rememberPendingInjectedTerminalJob(state, updated.parentSessionID, {
+      taskID: updated.taskID,
+      generation: updated.generation,
+    });
   }
   }
 
 
   log('[task-session-manager] processed injected background completion', {
   log('[task-session-manager] processed injected background completion', {
@@ -292,36 +296,68 @@ export function isMissingRememberedSessionError(output: string): boolean {
   );
   );
 }
 }
 
 
+function executionKey(execution: BackgroundJobExecution): string {
+  return `${execution.taskID}\u001f${execution.generation}`;
+}
+
+function rememberPendingInjectedTerminalJob(
+  state: InjectionState,
+  parentSessionID: string,
+  execution: BackgroundJobExecution,
+): void {
+  const pending =
+    state.pendingInjectedTerminalJobsByParent.get(parentSessionID) ??
+    new Map<string, BackgroundJobExecution>();
+  pending.set(executionKey(execution), { ...execution });
+  state.pendingInjectedTerminalJobsByParent.set(parentSessionID, pending);
+}
+
 export function rememberInjectedTerminalJobs(
 export function rememberInjectedTerminalJobs(
   state: InjectionState,
   state: InjectionState,
   parentSessionID: string,
   parentSessionID: string,
-  taskIDs: readonly string[],
-  promptShapeKey = '',
+  executions: readonly BackgroundJobExecution[],
+  promptShapeKey: string,
 ): void {
 ): void {
-  if (!parentSessionID || !taskIDs || taskIDs.length === 0) return;
+  if (!parentSessionID || executions.length === 0) return;
 
 
-  const uniqueTaskIDs = [...new Set(taskIDs)].filter(Boolean);
-  if (uniqueTaskIDs.length === 0) return;
+  const uniqueExecutions = new Map(
+    executions.map((execution) => [executionKey(execution), execution]),
+  );
+  if (uniqueExecutions.size === 0) return;
 
 
   const existing = state.terminalJobsInjectedByParent.get(parentSessionID);
   const existing = state.terminalJobsInjectedByParent.get(parentSessionID);
   if (existing && existing.promptShapeKey === promptShapeKey) {
   if (existing && existing.promptShapeKey === promptShapeKey) {
-    // Same prompt shape: union the IDs delivered by each payload.
-    for (const taskID of uniqueTaskIDs) {
-      existing.taskIDs.add(taskID);
+    // Same prompt shape: union the executions delivered by each payload.
+    for (const [key, execution] of uniqueExecutions) {
+      existing.executions.set(key, { ...execution });
     }
     }
   } else {
   } else {
     // A different shape is normally reconciled before this point. Replace
     // A different shape is normally reconciled before this point. Replace
-    // the entry defensively so IDs from an older payload cannot leak into the
-    // new delivered batch.
+    // the entry defensively so executions from an older payload cannot leak
+    // into the new delivered batch.
     state.terminalJobsInjectedByParent.set(parentSessionID, {
     state.terminalJobsInjectedByParent.set(parentSessionID, {
-      taskIDs: new Set(uniqueTaskIDs),
+      executions: new Map(
+        [...uniqueExecutions].map(([key, execution]) => [
+          key,
+          { ...execution },
+        ]),
+      ),
       promptShapeKey,
       promptShapeKey,
     });
     });
   }
   }
 
 
+  const pending =
+    state.pendingInjectedTerminalJobsByParent.get(parentSessionID);
+  if (pending) {
+    for (const key of uniqueExecutions.keys()) pending.delete(key);
+    if (pending.size === 0) {
+      state.pendingInjectedTerminalJobsByParent.delete(parentSessionID);
+    }
+  }
+
   log('[task-session-manager] terminal jobs injected for reconciliation', {
   log('[task-session-manager] terminal jobs injected for reconciliation', {
     parentSessionID,
     parentSessionID,
-    taskIDs: uniqueTaskIDs,
+    executions: [...uniqueExecutions.values()],
     promptShapeKey,
     promptShapeKey,
   });
   });
 }
 }
@@ -331,17 +367,37 @@ export function reconcileInjectedTerminalJobs(
   parentSessionID: string,
   parentSessionID: string,
 ): void {
 ): void {
   const entry = state.terminalJobsInjectedByParent.get(parentSessionID);
   const entry = state.terminalJobsInjectedByParent.get(parentSessionID);
-  if (!entry) return;
+  const pending =
+    state.pendingInjectedTerminalJobsByParent.get(parentSessionID);
+  if (!entry && !pending) return;
+
+  const executions = new Map<string, BackgroundJobExecution>();
+  for (const [key, execution] of entry?.executions ?? []) {
+    executions.set(key, execution);
+  }
+  for (const [key, execution] of pending ?? []) {
+    executions.set(key, execution);
+  }
 
 
   log('[task-session-manager] reconciling injected terminal jobs', {
   log('[task-session-manager] reconciling injected terminal jobs', {
     parentSessionID,
     parentSessionID,
-    taskIDs: [...entry.taskIDs],
+    executions: [...executions.values()],
   });
   });
 
 
-  for (const taskID of entry.taskIDs) {
-    state.backgroundJobBoard.markReconciled(taskID);
+  for (const execution of executions.values()) {
+    const current = state.backgroundJobBoard.get(execution.taskID);
+    if (!current || current.generation !== execution.generation) {
+      log('[task-session-manager] skipped stale terminal execution', {
+        parentSessionID,
+        execution,
+        currentGeneration: current?.generation,
+      });
+      continue;
+    }
+    state.backgroundJobBoard.markReconciled(execution.taskID);
   }
   }
   state.terminalJobsInjectedByParent.delete(parentSessionID);
   state.terminalJobsInjectedByParent.delete(parentSessionID);
+  state.pendingInjectedTerminalJobsByParent.delete(parentSessionID);
 }
 }
 
 
 function reconcileConsumedTerminalJobs(
 function reconcileConsumedTerminalJobs(
@@ -350,13 +406,7 @@ function reconcileConsumedTerminalJobs(
   promptShapeKey: string,
   promptShapeKey: string,
 ): void {
 ): void {
   const entry = state.terminalJobsInjectedByParent.get(parentSessionID);
   const entry = state.terminalJobsInjectedByParent.get(parentSessionID);
-  if (
-    !entry ||
-    entry.promptShapeKey === '' ||
-    entry.promptShapeKey === promptShapeKey
-  ) {
-    return;
-  }
+  if (!entry || entry.promptShapeKey === promptShapeKey) return;
   // The model produced at least one new part after the request that carried
   // The model produced at least one new part after the request that carried
   // these completions, so it has consumed them. Stop re-announcing.
   // these completions, so it has consumed them. Stop re-announcing.
   reconcileInjectedTerminalJobs(state, parentSessionID);
   reconcileInjectedTerminalJobs(state, parentSessionID);
@@ -676,7 +726,7 @@ function replayBoardSnapshots(
   sessionID: string,
   sessionID: string,
   snapshotState: RetainedBoardSnapshotState,
   snapshotState: RetainedBoardSnapshotState,
   metadataKey: string,
   metadataKey: string,
-): string[] {
+): BackgroundJobExecution[] {
   const realMessageList = realMessages(messages, metadataKey);
   const realMessageList = realMessages(messages, metadataKey);
   const currentAnchorKeys = messageAnchorKeys(realMessageList);
   const currentAnchorKeys = messageAnchorKeys(realMessageList);
   const snapshotsByAnchor = new Map<string, RetainedBoardSnapshot[]>();
   const snapshotsByAnchor = new Map<string, RetainedBoardSnapshot[]>();
@@ -693,7 +743,7 @@ function replayBoardSnapshots(
   );
   );
 
 
   const rebuiltMessages: unknown[] = [];
   const rebuiltMessages: unknown[] = [];
-  const replayedIDs: string[] = [];
+  const replayedIDs: BackgroundJobExecution[] = [];
   let realMessageIndex = 0;
   let realMessageIndex = 0;
   for (const message of messages) {
   for (const message of messages) {
     rebuiltMessages.push(message);
     rebuiltMessages.push(message);
@@ -731,7 +781,7 @@ function replayCheckpointBoard(
   sessionID: string,
   sessionID: string,
   snapshotState: RetainedBoardSnapshotState,
   snapshotState: RetainedBoardSnapshotState,
   metadataKey: string,
   metadataKey: string,
-): string[] {
+): BackgroundJobExecution[] {
   stripTaggedContent(messages, metadataKey);
   stripTaggedContent(messages, metadataKey);
   const ids = replayBoardSnapshots(
   const ids = replayBoardSnapshots(
     messages,
     messages,

+ 9 - 1
src/hooks/task-session-manager/event-router.ts

@@ -5,6 +5,7 @@
  * session.idle, session.error, session.status, session.deleted) to
  * session.idle, session.error, session.status, session.deleted) to
  * the appropriate subsystems.
  * the appropriate subsystems.
  */
  */
+import type { BackgroundJobExecution } from '../../utils/background-job-board';
 import type { BackgroundJobStore } from '../../utils/background-job-store';
 import type { BackgroundJobStore } from '../../utils/background-job-store';
 import { log } from '../../utils/logger';
 import { log } from '../../utils/logger';
 import { isFailoverError } from '../foreground-fallback/index';
 import { isFailoverError } from '../foreground-fallback/index';
@@ -78,6 +79,10 @@ export async function handleEvent(
       prune(board: { taskIDs(): Set<string> }): void;
       prune(board: { taskIDs(): Set<string> }): void;
     };
     };
     terminalJobsInjectedByParent: Map<string, InjectedTerminalJobs>;
     terminalJobsInjectedByParent: Map<string, InjectedTerminalJobs>;
+    pendingInjectedTerminalJobsByParent: Map<
+      string,
+      Map<string, BackgroundJobExecution>
+    >;
     retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
     retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
   },
   },
 ): Promise<void> {
 ): Promise<void> {
@@ -174,7 +179,9 @@ export async function handleEvent(
         ? deps.options.shouldManageSession(sessionId)
         ? deps.options.shouldManageSession(sessionId)
         : false,
         : false,
       terminalJobsPending: sessionId
       terminalJobsPending: sessionId
-        ? (deps.terminalJobsInjectedByParent.get(sessionId)?.taskIDs.size ?? 0)
+        ? (deps.terminalJobsInjectedByParent.get(sessionId)?.executions.size ??
+            0) +
+          (deps.pendingInjectedTerminalJobsByParent.get(sessionId)?.size ?? 0)
         : 0,
         : 0,
       runningJobForSession: job?.state === 'running' || false,
       runningJobForSession: job?.state === 'running' || false,
     });
     });
@@ -210,6 +217,7 @@ export async function handleEvent(
       const props = input.event.properties as { error?: unknown } | undefined;
       const props = input.event.properties as { error?: unknown } | undefined;
       if (!props?.error || !isFailoverError(props.error)) {
       if (!props?.error || !isFailoverError(props.error)) {
         deps.terminalJobsInjectedByParent.delete(sessionId);
         deps.terminalJobsInjectedByParent.delete(sessionId);
+        deps.pendingInjectedTerminalJobsByParent.delete(sessionId);
         // Record non-retryable errors on the job board so the
         // Record non-retryable errors on the job board so the
         // orchestrator sees the failure instead of a false completion.
         // orchestrator sees the failure instead of a false completion.
         const job = deps.backgroundJobBoard.get(sessionId);
         const job = deps.backgroundJobBoard.get(sessionId);

+ 149 - 1
src/hooks/task-session-manager/index.test.ts

@@ -1477,6 +1477,74 @@ describe('task-session-manager hook', () => {
     });
     });
   });
   });
 
 
+  test('a later synthetic completion does not replace an older delivered terminal batch', async () => {
+    const board = new BackgroundJobBoard({ maxReusablePerAgent: 3 });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      idleReconcileDelayMs: 0,
+    });
+
+    for (const taskID of ['child-1', 'child-2']) {
+      board.registerLaunch({
+        taskID,
+        parentSessionID: 'parent-1',
+        agent: 'oracle',
+        description: taskID,
+      });
+      board.updateStatus({ taskID, state: 'completed' });
+    }
+
+    // The first board payload records both executions as delivered.
+    await transformMessages(hook, createMessages('parent-1', 'first turn'));
+
+    board.registerLaunch({
+      taskID: 'child-3',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'child-3',
+    });
+    const laterCompletion = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              id: 'child-3-completion',
+              synthetic: true,
+              text: [
+                '<task id="child-3" state="completed">',
+                '<summary>Background task completed: child-3</summary>',
+                '<task_result>done3</task_result>',
+                '</task>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    // Process the later synthetic completion without rendering a new board.
+    await hook['experimental.chat.messages.transform'](
+      {},
+      laterCompletion as never,
+    );
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    await flushChildIdleReconcile();
+
+    for (const taskID of ['child-1', 'child-2', 'child-3']) {
+      expect(board.get(taskID)).toMatchObject({
+        state: 'reconciled',
+        terminalUnreconciled: false,
+      });
+    }
+  });
+
   test('no-starvation latest pipeline: child-1 synthetic remembered; child-2 becomes terminal before idle; next full transform emits child-2 in board; idle reconciles both', async () => {
   test('no-starvation latest pipeline: child-1 synthetic remembered; child-2 becomes terminal before idle; next full transform emits child-2 in board; idle reconciles both', async () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
     const { hook } = createHook({ backgroundJobBoard: board });
@@ -1570,7 +1638,7 @@ describe('task-session-manager hook', () => {
       return {
       return {
         text: shapedText,
         text: shapedText,
         terminalUnreconciledTaskIDs: m.terminalUnreconciledTaskIDs.filter(
         terminalUnreconciledTaskIDs: m.terminalUnreconciledTaskIDs.filter(
-          (id: string) => id === 'child-1',
+          (execution) => execution.taskID === 'child-1',
         ),
         ),
       };
       };
     };
     };
@@ -1705,6 +1773,86 @@ describe('task-session-manager hook', () => {
     });
     });
   });
   });
 
 
+  test('checkpoint replay does not reconcile a relaunch with the same task ID', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+      idleReconcileDelayMs: 0,
+    });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'first execution',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'completed' });
+
+    const firstRequest = createAnchoredMessages('parent-1', ['turn 1']);
+    await transformMessages(hook, firstRequest);
+    expect(boardSnapshotIDs(firstRequest)).toHaveLength(1);
+    expect(board.get('child-1')).toMatchObject({
+      generation: 1,
+      terminalUnreconciled: true,
+    });
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    await flushChildIdleReconcile();
+    expect(board.get('child-1')).toMatchObject({
+      generation: 1,
+      state: 'reconciled',
+    });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'second execution',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'second result',
+    });
+    expect(board.get('child-1')).toMatchObject({
+      generation: 2,
+      terminalUnreconciled: true,
+    });
+
+    // Hide the current board payload so only the stale generation-1 snapshot
+    // is delivered on this request.
+    board.formatForPromptWithMetadata = () => undefined;
+    const replayedRequest = createAnchoredMessages('parent-1', [
+      'turn 1',
+      'turn 2',
+    ]);
+    await transformMessages(hook, replayedRequest);
+    expect(boardSnapshotIDs(replayedRequest)).toEqual([
+      'oh-my-opencode-slim:background-job-board:parent-1:0',
+    ]);
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    await flushChildIdleReconcile();
+
+    expect(board.get('child-1')).toMatchObject({
+      generation: 2,
+      state: 'completed',
+      terminalUnreconciled: true,
+      resultSummary: 'second result',
+    });
+  });
+
   test('ignores non-synthetic user text that resembles task status', async () => {
   test('ignores non-synthetic user text that resembles task status', async () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
     const { hook } = createHook({ backgroundJobBoard: board });

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

@@ -1,6 +1,7 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { PluginInput } from '@opencode-ai/plugin';
 import {
 import {
   BackgroundJobBoard,
   BackgroundJobBoard,
+  type BackgroundJobExecution,
   type BackgroundJobStore,
   type BackgroundJobStore,
   isInternalInitiatorPart,
   isInternalInitiatorPart,
 } from '../../utils';
 } from '../../utils';
@@ -85,6 +86,10 @@ export function createTaskSessionManagerHook(
   const processedInjectedCompletions = new Set<string>();
   const processedInjectedCompletions = new Set<string>();
   const processedInjectedCompletionOrder: string[] = [];
   const processedInjectedCompletionOrder: string[] = [];
   const terminalJobsInjectedByParent = new Map<string, InjectedTerminalJobs>();
   const terminalJobsInjectedByParent = new Map<string, InjectedTerminalJobs>();
+  const pendingInjectedTerminalJobsByParent = new Map<
+    string,
+    Map<string, BackgroundJobExecution>
+  >();
 
 
   // Forward refs for circular deps — set after corresponding managers exist.
   // Forward refs for circular deps — set after corresponding managers exist.
   // These are captured by closure in createIdleReconciler and only called
   // These are captured by closure in createIdleReconciler and only called
@@ -172,6 +177,7 @@ export function createTaskSessionManagerHook(
         backgroundJobBoard.clearParent(sessionId);
         backgroundJobBoard.clearParent(sessionId);
       }
       }
       terminalJobsInjectedByParent.delete(sessionId);
       terminalJobsInjectedByParent.delete(sessionId);
+      pendingInjectedTerminalJobsByParent.delete(sessionId);
       injectionState.retainedBoardSnapshots.delete(sessionId);
       injectionState.retainedBoardSnapshots.delete(sessionId);
       taskContextTracker.clearSession(sessionId);
       taskContextTracker.clearSession(sessionId);
       taskContextTracker.prune(backgroundJobBoard);
       taskContextTracker.prune(backgroundJobBoard);
@@ -186,6 +192,7 @@ export function createTaskSessionManagerHook(
     processedInjectedCompletions,
     processedInjectedCompletions,
     processedInjectedCompletionOrder,
     processedInjectedCompletionOrder,
     terminalJobsInjectedByParent,
     terminalJobsInjectedByParent,
+    pendingInjectedTerminalJobsByParent,
     maxProcessedInjectedCompletions: MAX_PROCESSED_INJECTED_COMPLETIONS,
     maxProcessedInjectedCompletions: MAX_PROCESSED_INJECTED_COMPLETIONS,
     metadataKey: BACKGROUND_JOB_BOARD_METADATA_KEY,
     metadataKey: BACKGROUND_JOB_BOARD_METADATA_KEY,
     shouldManageSession: options.shouldManageSession,
     shouldManageSession: options.shouldManageSession,
@@ -335,6 +342,7 @@ export function createTaskSessionManagerHook(
         pendingCallTracker,
         pendingCallTracker,
         taskContextTracker,
         taskContextTracker,
         terminalJobsInjectedByParent,
         terminalJobsInjectedByParent,
+        pendingInjectedTerminalJobsByParent,
         retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
         retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
       }),
       }),
   };
   };

+ 9 - 2
src/utils/background-job-board.test.ts

@@ -166,8 +166,15 @@ describe('BackgroundJobBoard', () => {
     const metadata = board.formatForPromptWithMetadata('parent-1');
     const metadata = board.formatForPromptWithMetadata('parent-1');
 
 
     expect(metadata?.text).toBe(board.formatForPrompt('parent-1'));
     expect(metadata?.text).toBe(board.formatForPrompt('parent-1'));
-    expect(metadata?.terminalUnreconciledTaskIDs).toEqual(['ses_1', 'ses_2']);
-    expect(metadata?.terminalUnreconciledTaskIDs).not.toContain('ses_other');
+    expect(metadata?.terminalUnreconciledTaskIDs).toEqual([
+      { taskID: 'ses_1', generation: 1 },
+      { taskID: 'ses_2', generation: 1 },
+    ]);
+    expect(
+      metadata?.terminalUnreconciledTaskIDs.some(
+        (execution) => execution.taskID === 'ses_other',
+      ),
+    ).toBe(false);
   });
   });
 
 
   test('escapes dynamic job content inside system reminders', () => {
   test('escapes dynamic job content inside system reminders', () => {

+ 15 - 4
src/utils/background-job-board.ts

@@ -16,10 +16,21 @@ export interface ContextFile {
   lastReadAt: number;
   lastReadAt: number;
 }
 }
 
 
+export interface BackgroundJobExecution {
+  taskID: string;
+  generation: number;
+}
+
+export interface BackgroundJobPromptMetadata {
+  text: string | undefined;
+  terminalUnreconciledTaskIDs: BackgroundJobExecution[];
+}
+
 export type BackgroundJobState = TaskOutputState | 'reconciled';
 export type BackgroundJobState = TaskOutputState | 'reconciled';
 
 
 export interface BackgroundJobRecord {
 export interface BackgroundJobRecord {
   taskID: string;
   taskID: string;
+  generation: number;
   parentSessionID: string;
   parentSessionID: string;
   agent: string;
   agent: string;
   description: string;
   description: string;
@@ -144,6 +155,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     if (existing) {
     if (existing) {
       const updated = {
       const updated = {
         ...existing,
         ...existing,
+        generation: existing.generation + 1,
         agent: input.agent || existing.agent,
         agent: input.agent || existing.agent,
         description: input.description || existing.description,
         description: input.description || existing.description,
         objective: input.objective ?? existing.objective,
         objective: input.objective ?? existing.objective,
@@ -170,6 +182,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
 
     const record: BackgroundJobRecord = {
     const record: BackgroundJobRecord = {
       taskID: input.taskID,
       taskID: input.taskID,
+      generation: 1,
       parentSessionID: input.parentSessionID,
       parentSessionID: input.parentSessionID,
       agent: input.agent,
       agent: input.agent,
       description: input.description || `background ${input.agent} task`,
       description: input.description || `background ${input.agent} task`,
@@ -498,9 +511,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
   formatForPromptWithMetadata(
   formatForPromptWithMetadata(
     parentSessionID: string,
     parentSessionID: string,
     _now?: number,
     _now?: number,
-  ):
-    | { text: string | undefined; terminalUnreconciledTaskIDs: string[] }
-    | undefined {
+  ): BackgroundJobPromptMetadata | undefined {
     const jobs = this.list(parentSessionID);
     const jobs = this.list(parentSessionID);
     const active = jobs.filter(
     const active = jobs.filter(
       (job) => job.state === 'running' || job.terminalUnreconciled,
       (job) => job.state === 'running' || job.terminalUnreconciled,
@@ -529,7 +540,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
 
     const terminalUnreconciledTaskIDs = active
     const terminalUnreconciledTaskIDs = active
       .filter((job) => job.terminalUnreconciled)
       .filter((job) => job.terminalUnreconciled)
-      .map((job) => job.taskID);
+      .map(({ taskID, generation }) => ({ taskID, generation }));
 
 
     return { text, terminalUnreconciledTaskIDs };
     return { text, terminalUnreconciledTaskIDs };
   }
   }

+ 2 - 3
src/utils/background-job-coordinator.ts

@@ -1,6 +1,7 @@
 import type {
 import type {
   BackgroundJobBoard,
   BackgroundJobBoard,
   BackgroundJobLaunchInput,
   BackgroundJobLaunchInput,
+  BackgroundJobPromptMetadata,
   BackgroundJobRecord,
   BackgroundJobRecord,
   BackgroundJobStatusInput,
   BackgroundJobStatusInput,
   ContextFile,
   ContextFile,
@@ -239,9 +240,7 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
   formatForPromptWithMetadata(
   formatForPromptWithMetadata(
     parentSessionID: string,
     parentSessionID: string,
     now = Date.now(),
     now = Date.now(),
-  ):
-    | { text: string | undefined; terminalUnreconciledTaskIDs: string[] }
-    | undefined {
+  ): BackgroundJobPromptMetadata | undefined {
     return this.board.formatForPromptWithMetadata(parentSessionID, now);
     return this.board.formatForPromptWithMetadata(parentSessionID, now);
   }
   }
 
 

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

@@ -1,5 +1,6 @@
 import type {
 import type {
   BackgroundJobLaunchInput,
   BackgroundJobLaunchInput,
+  BackgroundJobPromptMetadata,
   BackgroundJobRecord,
   BackgroundJobRecord,
   BackgroundJobStatusInput,
   BackgroundJobStatusInput,
   ContextFile,
   ContextFile,
@@ -70,9 +71,7 @@ export interface BackgroundJobStore {
   formatForPromptWithMetadata(
   formatForPromptWithMetadata(
     parentSessionID: string,
     parentSessionID: string,
     now?: number,
     now?: number,
-  ):
-    | { text: string | undefined; terminalUnreconciledTaskIDs: string[] }
-    | undefined;
+  ): BackgroundJobPromptMetadata | undefined;
 
 
   // ── Lifecycle policy ─────────────────────────────────────────────
   // ── Lifecycle policy ─────────────────────────────────────────────
   /** Evaluate close policy. Returns true if session should close now.
   /** Evaluate close policy. Returns true if session should close now.