Browse Source

Merge pull request #921 into omos/pr-921-conflict

Alvin Unreal 2 weeks ago
parent
commit
cbbf2e25ad

+ 65 - 26
src/hooks/task-session-manager/board-injection.ts

@@ -49,6 +49,7 @@ type RetainedBoardSnapshot = {
   anchorKey: string;
   id: string;
   text: string;
+  terminalUnreconciledTaskIDs: string[];
 };
 
 export type RetainedBoardSnapshotState = {
@@ -62,7 +63,10 @@ export type RetainedBoardSnapshotState = {
 
 export type InjectedTerminalJobs = {
   taskIDs: Set<string>;
-  /** Prompt shape when these task IDs were last surfaced to the model. */
+  /**
+   * Prompt shape when these task IDs were last surfaced to the model.
+   * Empty when a synthetic completion was processed before board injection.
+   */
   promptShapeKey: string;
 };
 
@@ -224,6 +228,11 @@ export function updateFromInjectedCompletion(
       result: status.result,
     });
     rememberProcessedInjectedCompletion(state, occurrenceId);
+    if (existing?.terminalUnreconciled && existing?.parentSessionID) {
+      rememberInjectedTerminalJobs(state, existing.parentSessionID, [
+        existing.taskID,
+      ]);
+    }
     return existing;
   }
 
@@ -239,6 +248,12 @@ export function updateFromInjectedCompletion(
   );
   if (!updated) return undefined;
 
+  if (updated.terminalUnreconciled && updated.parentSessionID) {
+    rememberInjectedTerminalJobs(state, updated.parentSessionID, [
+      updated.taskID,
+    ]);
+  }
+
   log('[task-session-manager] processed injected background completion', {
     taskID: updated.taskID,
     alias: updated.alias,
@@ -280,32 +295,35 @@ export function isMissingRememberedSessionError(output: string): boolean {
 export function rememberInjectedTerminalJobs(
   state: InjectionState,
   parentSessionID: string,
-  promptShapeKey: string,
+  taskIDs: readonly string[],
+  promptShapeKey = '',
 ): void {
-  const taskIDs = state.backgroundJobBoard
-    .list(parentSessionID)
-    .filter((job) => job.terminalUnreconciled)
-    .map((job) => job.taskID);
-  if (taskIDs.length === 0) return;
+  if (!parentSessionID || !taskIDs || taskIDs.length === 0) return;
 
-  log('[task-session-manager] terminal jobs injected for reconciliation', {
-    parentSessionID,
-    taskIDs,
-  });
+  const uniqueTaskIDs = [...new Set(taskIDs)].filter(Boolean);
+  if (uniqueTaskIDs.length === 0) return;
 
   const existing = state.terminalJobsInjectedByParent.get(parentSessionID);
   if (existing && existing.promptShapeKey === promptShapeKey) {
-    // Same prompt shape: union the task IDs into the existing set
-    for (const taskID of taskIDs) {
+    // Same prompt shape: union the IDs delivered by each payload.
+    for (const taskID of uniqueTaskIDs) {
       existing.taskIDs.add(taskID);
     }
   } else {
-    // Different prompt shape or new entry: overwrite
+    // 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.
     state.terminalJobsInjectedByParent.set(parentSessionID, {
-      taskIDs: new Set(taskIDs),
+      taskIDs: new Set(uniqueTaskIDs),
       promptShapeKey,
     });
   }
+
+  log('[task-session-manager] terminal jobs injected for reconciliation', {
+    parentSessionID,
+    taskIDs: uniqueTaskIDs,
+    promptShapeKey,
+  });
 }
 
 export function reconcileInjectedTerminalJobs(
@@ -332,7 +350,13 @@ function reconcileConsumedTerminalJobs(
   promptShapeKey: string,
 ): void {
   const entry = state.terminalJobsInjectedByParent.get(parentSessionID);
-  if (!entry || entry.promptShapeKey === promptShapeKey) return;
+  if (
+    !entry ||
+    entry.promptShapeKey === '' ||
+    entry.promptShapeKey === promptShapeKey
+  ) {
+    return;
+  }
   // The model produced at least one new part after the request that carried
   // these completions, so it has consumed them. Stop re-announcing.
   reconcileInjectedTerminalJobs(state, parentSessionID);
@@ -382,12 +406,18 @@ export async function injectBackgroundJobBoard(
     const shapeKey = promptShapeKey(realMessages(messages, state.metadataKey));
     reconcileConsumedTerminalJobs(state, message.info.sessionID, shapeKey);
 
-    const reminder = state.backgroundJobBoard.formatForPrompt(
+    const boardMeta = state.backgroundJobBoard.formatForPromptWithMetadata(
       message.info.sessionID,
     );
+    const reminder = boardMeta?.text;
     if (!reminder) return;
 
-    rememberInjectedTerminalJobs(state, message.info.sessionID, shapeKey);
+    rememberInjectedTerminalJobs(
+      state,
+      message.info.sessionID,
+      boardMeta.terminalUnreconciledTaskIDs,
+      shapeKey,
+    );
     // 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
@@ -437,7 +467,9 @@ function injectCheckpointBoard(
 
   if (canSurface) reconcileConsumedTerminalJobs(state, sessionID, shapeKey);
 
-  const reminder = state.backgroundJobBoard.formatForPrompt(sessionID);
+  const boardMeta =
+    state.backgroundJobBoard.formatForPromptWithMetadata(sessionID);
+  const reminder = boardMeta?.text;
   const canCreateSnapshot = canSurface && reminder !== undefined;
 
   const replayBaseMessage = triggeringMessage ?? tailMessage;
@@ -461,18 +493,21 @@ function injectCheckpointBoard(
         anchorKey,
         id: `oh-my-opencode-slim:background-job-board:${encodedSessionID}:${sequence}`,
         text: reminder,
+        terminalUnreconciledTaskIDs: boardMeta.terminalUnreconciledTaskIDs,
       });
     }
-    rememberInjectedTerminalJobs(state, sessionID, shapeKey);
   }
 
-  replayCheckpointBoard(
+  const replayedIDs = replayCheckpointBoard(
     messages,
     replayBaseMessage,
     sessionID,
     snapshotState,
     state.metadataKey,
   );
+  if (replayedIDs.length > 0) {
+    rememberInjectedTerminalJobs(state, sessionID, replayedIDs, shapeKey);
+  }
 }
 
 function findLastMessageAnchorKey(
@@ -641,7 +676,7 @@ function replayBoardSnapshots(
   sessionID: string,
   snapshotState: RetainedBoardSnapshotState,
   metadataKey: string,
-): void {
+): string[] {
   const realMessageList = realMessages(messages, metadataKey);
   const currentAnchorKeys = messageAnchorKeys(realMessageList);
   const snapshotsByAnchor = new Map<string, RetainedBoardSnapshot[]>();
@@ -658,6 +693,7 @@ function replayBoardSnapshots(
   );
 
   const rebuiltMessages: unknown[] = [];
+  const replayedIDs: string[] = [];
   let realMessageIndex = 0;
   for (const message of messages) {
     rebuiltMessages.push(message);
@@ -679,10 +715,14 @@ function replayBoardSnapshots(
           usedMessageIDs,
         ),
       );
+      if (snapshot.terminalUnreconciledTaskIDs?.length) {
+        replayedIDs.push(...snapshot.terminalUnreconciledTaskIDs);
+      }
     }
   }
 
   messages.splice(0, messages.length, ...rebuiltMessages);
+  return replayedIDs;
 }
 
 function replayCheckpointBoard(
@@ -691,15 +731,14 @@ function replayCheckpointBoard(
   sessionID: string,
   snapshotState: RetainedBoardSnapshotState,
   metadataKey: string,
-): void {
+): string[] {
   stripTaggedContent(messages, metadataKey);
-  replayBoardSnapshots(
+  const ids = replayBoardSnapshots(
     messages,
     baseMessage,
     sessionID,
     snapshotState,
     metadataKey,
   );
-  // The caller records terminal jobs before this replay so that the normal
-  // idle reconciliation path can consume them after the prompt is processed.
+  return ids;
 }

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

@@ -1319,6 +1319,392 @@ describe('task-session-manager hook', () => {
     );
   });
 
+  test('injected completion through message transform (without injectBackgroundJobBoard) remains terminal-unreconciled before parent idle, then reconciles after', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: {
+          subagent_type: 'explorer',
+          description: 'map hooks',
+        },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        output: ['task_id: child-1', 'state: running'].join('\n'),
+      },
+    );
+
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              id: 'part-1',
+              synthetic: true,
+              text: [
+                '<task id="child-1" state="completed">',
+                '<summary>Background task completed: map hooks</summary>',
+                '<task_result>',
+                'found hook flow',
+                '</task_result>',
+                '</task>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    // through transform only, without injectBackgroundJobBoard (avoids broad remember)
+    await hook['experimental.chat.messages.transform']({}, messages as never);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+      resultSummary: 'found hook flow',
+    });
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
+
+    // duplicate occurrence is idempotent (no reprocess, no double remember)
+    await hook['experimental.chat.messages.transform']({}, messages as never);
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+
+    await flushIdleReconcileDelay();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('another terminal-unreconciled sibling remains unreconciled when only first child completion was injected', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    // setup child-1
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: { subagent_type: 'explorer', description: 'first' },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { output: ['task_id: child-1', 'state: running'].join('\n') },
+    );
+
+    // setup sibling child-2 (terminal via updateStatus after board payload, no injected)
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      {
+        args: { subagent_type: 'oracle', description: 'second' },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      { output: ['task_id: child-2', 'state: running'].join('\n') },
+    );
+
+    // Full production sequence: transformMessages ... Only child-1 synthetic.
+    // child-2 still running so not in terminalUnreconciled IDs of this payload.
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              id: 'part-1',
+              synthetic: true,
+              text: [
+                '<task id="child-1" state="completed">',
+                '<summary>Background task completed: first</summary>',
+                '<task_result>done1</task_result>',
+                '</task>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+    await transformMessages(hook, messages);
+
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
+    expect(board.get('child-2')?.terminalUnreconciled).toBe(false);
+
+    // duplicate stays idempotent
+    await transformMessages(hook, messages);
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
+
+    // now make child-2 terminal (after the board payload was emitted)
+    board.updateStatus({
+      taskID: 'child-2',
+      state: 'completed',
+      resultSummary: 'sibling done',
+    });
+    expect(board.get('child-2')?.terminalUnreconciled).toBe(true);
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    await flushIdleReconcileDelay();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    // sibling terminal but never appeared in board payload nor had synthetic injected
+    expect(board.get('child-2')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+    });
+  });
+
+  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 { hook } = createHook({ backgroundJobBoard: board });
+
+    // child-1 via tool + synthetic injected (narrow + metadata will remember it)
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'explorer', description: 'first' } },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { output: ['task_id: child-1', 'state: running'].join('\n') },
+    );
+
+    const msg1 = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              id: 'part-1',
+              synthetic: true,
+              text: [
+                '<task id="child-1" state="completed">',
+                '<summary>Background task completed: first</summary>',
+                '<task_result>done1</task_result>',
+                '</task>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+    await transformMessages(hook, msg1);
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
+
+    // before idle, child-2 becomes terminal (no synthetic for it)
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      { args: { subagent_type: 'oracle', description: 'second' } },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      { output: ['task_id: child-2', 'state: running'].join('\n') },
+    );
+    board.updateStatus({
+      taskID: 'child-2',
+      state: 'completed',
+      resultSummary: 'done2',
+    });
+    expect(board.get('child-2')?.terminalUnreconciled).toBe(true);
+
+    // next full transform: emits board payload that now includes child-2 terminal
+    const msg2 = createMessages('parent-1', 'next turn');
+    await transformMessages(hook, msg2);
+    expect(boardText(msg2)).toContain('child-2');
+    expect(boardText(msg2)).toContain('completed, unreconciled');
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    await flushIdleReconcileDelay();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    expect(board.get('child-2')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('metadata/renderer selection omits child-2 from both board text and IDs; child-2 absent from emitted text and remains unreconciled', async () => {
+    const board = new BackgroundJobBoard();
+    // renderer-selection stub/fake: omits child-2 row from BOTH text and IDs (test-only shaping)
+    const orig = board.formatForPromptWithMetadata.bind(board);
+    board.formatForPromptWithMetadata = (p: string) => {
+      const m = orig(p);
+      if (!m) return m;
+      const shapedText = m.text
+        ? m.text
+            .split('\n')
+            .filter((line: string) => !line.includes('child-2'))
+            .join('\n')
+        : m.text;
+      return {
+        text: shapedText,
+        terminalUnreconciledTaskIDs: m.terminalUnreconciledTaskIDs.filter(
+          (id: string) => id === 'child-1',
+        ),
+      };
+    };
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'c1',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'd1',
+    });
+    board.registerLaunch({
+      taskID: 'child-2',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'c2',
+    });
+    board.updateStatus({
+      taskID: 'child-2',
+      state: 'completed',
+      resultSummary: 'd2',
+    });
+
+    const messages = createMessages('parent-1');
+    await transformMessages(hook, messages);
+
+    const emitted = boardText(messages);
+    expect(emitted).not.toContain('child-2');
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    await flushIdleReconcileDelay();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    expect(board.get('child-2')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+    });
+  });
+
+  test('checkpoint-compatible no-starvation via snapshot replay: child-1 synthetic; child-2 terminal no synthetic; second transform replays snapshot with child-2; board text has it; idle reconciles both', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+      idleReconcileDelayMs: 0,
+    });
+
+    // first: synthetic child-1 only
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'explorer', description: 'first' } },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { output: ['task_id: child-1', 'state: running'].join('\n') },
+    );
+
+    const msg1 = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              id: 'part-1',
+              synthetic: true,
+              text: [
+                '<task id="child-1" state="completed">',
+                '<summary>Background task completed: first</summary>',
+                '<task_result>done1</task_result>',
+                '</task>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+    await transformMessages(hook, msg1);
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
+
+    // child-2 becomes terminal without synthetic
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      { args: { subagent_type: 'oracle', description: 'second' } },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      { output: ['task_id: child-2', 'state: running'].join('\n') },
+    );
+    board.updateStatus({
+      taskID: 'child-2',
+      state: 'completed',
+      resultSummary: 'done2',
+    });
+    expect(board.get('child-2')?.terminalUnreconciled).toBe(true);
+
+    // second full transform (checkpoint): emits/replays snapshot containing child-2 (no narrow for child-2)
+    const msg2 = createMessages('parent-1', 'next');
+    await transformMessages(hook, msg2);
+    const replayedText = boardText(msg2);
+    expect(replayedText).toContain('child-2');
+    expect(replayedText).toContain('completed, unreconciled');
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    await flushContinuation();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    expect(board.get('child-2')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+  });
+
   test('ignores non-synthetic user text that resembles task status', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });

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

@@ -139,6 +139,37 @@ describe('BackgroundJobBoard', () => {
     expect(prompt).toEndWith('</system-reminder>');
   });
 
+  test('formats prompt metadata with only the terminal jobs in the payload', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'first result',
+    });
+    board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+    board.registerLaunch({
+      taskID: 'ses_2',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'second result',
+    });
+    board.updateStatus({ taskID: 'ses_2', state: 'completed' });
+    board.registerLaunch({
+      taskID: 'ses_other',
+      parentSessionID: 'parent-2',
+      agent: 'oracle',
+      description: 'other parent result',
+    });
+    board.updateStatus({ taskID: 'ses_other', state: 'completed' });
+
+    const metadata = board.formatForPromptWithMetadata('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');
+  });
+
   test('escapes dynamic job content inside system reminders', () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({

+ 17 - 2
src/utils/background-job-board.ts

@@ -495,7 +495,12 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     return errors >= threshold || timeouts >= threshold;
   }
 
-  formatForPrompt(parentSessionID: string, _now?: number): string | undefined {
+  formatForPromptWithMetadata(
+    parentSessionID: string,
+    _now?: number,
+  ):
+    | { text: string | undefined; terminalUnreconciledTaskIDs: string[] }
+    | undefined {
     const jobs = this.list(parentSessionID);
     const active = jobs.filter(
       (job) => job.state === 'running' || job.terminalUnreconciled,
@@ -504,7 +509,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
     if (active.length === 0 && reusable.length === 0) return undefined;
 
-    return formatSystemReminder(
+    const text = formatSystemReminder(
       [
         '### Background Job Board',
         'SENTINEL: background-job-board-v2',
@@ -521,6 +526,16 @@ export class BackgroundJobBoard implements BackgroundJobStore {
           : ['- none']),
       ].join('\n'),
     );
+
+    const terminalUnreconciledTaskIDs = active
+      .filter((job) => job.terminalUnreconciled)
+      .map((job) => job.taskID);
+
+    return { text, terminalUnreconciledTaskIDs };
+  }
+
+  formatForPrompt(parentSessionID: string, now?: number): string | undefined {
+    return this.formatForPromptWithMetadata(parentSessionID, now)?.text;
   }
 
   clearParent(parentSessionID: string): void {

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

@@ -236,6 +236,15 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
     return this.board.formatForPrompt(parentSessionID, now);
   }
 
+  formatForPromptWithMetadata(
+    parentSessionID: string,
+    now = Date.now(),
+  ):
+    | { text: string | undefined; terminalUnreconciledTaskIDs: string[] }
+    | undefined {
+    return this.board.formatForPromptWithMetadata(parentSessionID, now);
+  }
+
   clearParent(parentSessionID: string): void {
     this.board.clearParent(parentSessionID);
   }

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

@@ -67,6 +67,12 @@ export interface BackgroundJobStore {
   hasTerminalUnreconciled(parentSessionID: string): boolean;
   hasConvergenceSignals(taskID: string, threshold?: number): boolean;
   formatForPrompt(parentSessionID: string, now?: number): string | undefined;
+  formatForPromptWithMetadata(
+    parentSessionID: string,
+    now?: number,
+  ):
+    | { text: string | undefined; terminalUnreconciledTaskIDs: string[] }
+    | undefined;
 
   // ── Lifecycle policy ─────────────────────────────────────────────
   /** Evaluate close policy. Returns true if session should close now.