Browse Source

Merge pull request #925 from major/fix/wait-for-user-board-reconcile-loop

Alvin 1 week ago
parent
commit
5fb4c2111c

+ 108 - 22
src/hooks/task-session-manager/board-injection.ts

@@ -7,6 +7,7 @@
  * All injection logic must go through the cache-safe helpers in
  * ../cache-safe-injection.ts to ensure prompt cache safety.
  */
+import { createHash } from 'node:crypto';
 import type {
   BackgroundJobRecord,
   BackgroundJobStore,
@@ -59,13 +60,19 @@ export type RetainedBoardSnapshotState = {
 
 // ── State shape ────────────────────────────────────────────────────────
 
+export type InjectedTerminalJobs = {
+  taskIDs: Set<string>;
+  /** Prompt shape when these task IDs were last surfaced to the model. */
+  promptShapeKey: string;
+};
+
 export interface InjectionState {
   backgroundJobBoard: BackgroundJobStore;
   maxRetainedSnapshots: number;
   strategy: 'latest' | 'checkpoint-compatible';
   processedInjectedCompletions: Set<string>;
   processedInjectedCompletionOrder: string[];
-  terminalJobsInjectedByParent: Map<string, Set<string>>;
+  terminalJobsInjectedByParent: Map<string, InjectedTerminalJobs>;
   maxProcessedInjectedCompletions: number;
   metadataKey: string;
   shouldManageSession: (sessionID: string) => boolean;
@@ -87,6 +94,10 @@ function djb2Hash(str: string): string {
   return (hash >>> 0).toString(16).padStart(8, '0');
 }
 
+function sha256Hash(str: string): string {
+  return createHash('sha256').update(str).digest('hex');
+}
+
 function createOccurrenceId(
   part: MessagePart,
   message: MessageWithParts,
@@ -269,6 +280,7 @@ export function isMissingRememberedSessionError(output: string): boolean {
 export function rememberInjectedTerminalJobs(
   state: InjectionState,
   parentSessionID: string,
+  promptShapeKey: string,
 ): void {
   const taskIDs = state.backgroundJobBoard
     .list(parentSessionID)
@@ -281,33 +293,51 @@ export function rememberInjectedTerminalJobs(
     taskIDs,
   });
 
-  const existing =
-    state.terminalJobsInjectedByParent.get(parentSessionID) ??
-    new Set<string>();
-  for (const taskID of taskIDs) {
-    existing.add(taskID);
+  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) {
+      existing.taskIDs.add(taskID);
+    }
+  } else {
+    // Different prompt shape or new entry: overwrite
+    state.terminalJobsInjectedByParent.set(parentSessionID, {
+      taskIDs: new Set(taskIDs),
+      promptShapeKey,
+    });
   }
-  state.terminalJobsInjectedByParent.set(parentSessionID, existing);
 }
 
 export function reconcileInjectedTerminalJobs(
   state: InjectionState,
   parentSessionID: string,
 ): void {
-  const taskIDs = state.terminalJobsInjectedByParent.get(parentSessionID);
-  if (!taskIDs) return;
+  const entry = state.terminalJobsInjectedByParent.get(parentSessionID);
+  if (!entry) return;
 
   log('[task-session-manager] reconciling injected terminal jobs', {
     parentSessionID,
-    taskIDs: [...taskIDs],
+    taskIDs: [...entry.taskIDs],
   });
 
-  for (const taskID of taskIDs) {
+  for (const taskID of entry.taskIDs) {
     state.backgroundJobBoard.markReconciled(taskID);
   }
   state.terminalJobsInjectedByParent.delete(parentSessionID);
 }
 
+function reconcileConsumedTerminalJobs(
+  state: InjectionState,
+  parentSessionID: string,
+  promptShapeKey: string,
+): void {
+  const entry = state.terminalJobsInjectedByParent.get(parentSessionID);
+  if (!entry || 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);
+}
+
 export async function injectBackgroundJobBoard(
   state: InjectionState,
   _input: Record<string, never>,
@@ -344,17 +374,20 @@ export async function injectBackgroundJobBoard(
       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);
+    const shapeKey = promptShapeKey(realMessages(messages, state.metadataKey));
+    reconcileConsumedTerminalJobs(state, message.info.sessionID, shapeKey);
+
+    const reminder = state.backgroundJobBoard.formatForPrompt(
+      message.info.sessionID,
+    );
+    if (!reminder) return;
+
+    rememberInjectedTerminalJobs(state, message.info.sessionID, 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
@@ -381,6 +414,7 @@ function injectCheckpointBoard(
   messages: unknown[],
 ): void {
   const currentMessages = realMessages(messages, state.metadataKey);
+  const shapeKey = promptShapeKey(currentMessages);
   const tailMessage = currentMessages.at(-1);
   const sessionID = tailMessage?.info.sessionID;
   if (!tailMessage || !sessionID || !state.shouldManageSession(sessionID)) {
@@ -391,17 +425,20 @@ function injectCheckpointBoard(
     (message) =>
       isUserMessageWithParts(message) && message.info.sessionID === sessionID,
   );
-  const reminder = state.backgroundJobBoard.formatForPrompt(sessionID);
   const textPart = triggeringMessage?.parts.find(
     (part) => part.type === 'text' && typeof part.text === 'string',
   );
-  const canCreateSnapshot =
+  const canSurface =
     triggeringMessage !== undefined &&
     (!triggeringMessage.info.agent ||
       triggeringMessage.info.agent === 'orchestrator') &&
     textPart !== undefined &&
-    !isInternalInitiatorPart(textPart) &&
-    reminder !== undefined;
+    !isInternalInitiatorPart(textPart);
+
+  if (canSurface) reconcileConsumedTerminalJobs(state, sessionID, shapeKey);
+
+  const reminder = state.backgroundJobBoard.formatForPrompt(sessionID);
+  const canCreateSnapshot = canSurface && reminder !== undefined;
 
   const replayBaseMessage = triggeringMessage ?? tailMessage;
   const snapshotState = updateBoardHistoryState(
@@ -426,7 +463,7 @@ function injectCheckpointBoard(
         text: reminder,
       });
     }
-    rememberInjectedTerminalJobs(state, sessionID);
+    rememberInjectedTerminalJobs(state, sessionID, shapeKey);
   }
 
   replayCheckpointBoard(
@@ -482,6 +519,55 @@ function realMessages(
   });
 }
 
+/**
+ * Identity of the real prompt content/structure for one request. Stable across
+ * repeated transforms of the same request; changes as soon as relevant message
+ * or non-synthetic part content changes. Counts alone are insufficient because
+ * supported compaction can remove old content while a model turn appends new
+ * content, preserving message/part counts.
+ */
+function promptShapeKey(realMessageList: MessageWithParts[]): string {
+  const tokens: string[] = [];
+  tokens.push(`messages:${realMessageList.length}`);
+  for (const message of realMessageList) {
+    tokens.push('message');
+    tokens.push(`role:${message.info.role ?? ''}`);
+    tokens.push(`agent:${message.info.agent ?? ''}`);
+    tokens.push(`session:${message.info.sessionID ?? ''}`);
+    const realParts = message.parts.filter((part) => part.synthetic !== true);
+    tokens.push(`parts:${realParts.length}`);
+    for (const part of realParts) {
+      tokens.push('part');
+      tokens.push(stablePromptPartSignature(part));
+    }
+  }
+  return sha256Hash(tokens.join('\u001f'));
+}
+
+function stablePromptPartSignature(part: MessagePart): string {
+  return stableSerializePromptValue(part);
+}
+
+function stableSerializePromptValue(value: unknown): string {
+  if (value === null) return 'null';
+  const valueType = typeof value;
+  if (valueType === 'string') return JSON.stringify(value);
+  if (valueType === 'number' || valueType === 'boolean') return String(value);
+  if (Array.isArray(value)) {
+    return `[${value.map((item) => stableSerializePromptValue(item)).join(',')}]`;
+  }
+  if (isRecord(value)) {
+    return `{${Object.keys(value)
+      .sort()
+      .map(
+        (key) =>
+          `${JSON.stringify(key)}:${stableSerializePromptValue(value[key])}`,
+      )
+      .join(',')}}`;
+  }
+  return valueType;
+}
+
 function hasCompacted(
   previous: RetainedBoardSnapshotState,
   currentMessages: MessageWithParts[],

+ 8 - 7
src/hooks/task-session-manager/codemap.md

@@ -48,15 +48,16 @@ All modules depend on `BackgroundJobBoard` from `src/utils/background-job-board.
    - Prunes stale context during lifecycle events and status transitions
 
 4. **Message Injection (`experimental.chat.messages.transform`)**
-   - Injects a `<system-reminder>` part containing the `### Background Job Board` section into user messages for managed sessions
-   - Lists active, unreconciled, and reusable sessions
-   - Remembers injected terminal jobs to reconcile them on parent idle events
+    - Injects a `<system-reminder>` part containing the `### Background Job Board` section into user messages for managed sessions
+    - Lists active, unreconciled, and reusable sessions
+    - Remembers injected terminal jobs to reconcile them on the next request after the completion was surfaced to the model (via `reconcileConsumedTerminalJobs`)
+    - The idle timer remains as a backstop for when the model ends its turn without further requests
 
 5. **Lifecycle Events (`event`)**
-   - `session.created`: Adds new task IDs to pending managed set
-   - `session.idle` / `session.status` (idle): Reconciles injected terminal jobs for the parent session
-   - `session.status` (busy): Marks sessions as running from live session state
-   - `session.deleted`: Clears job state, child jobs, and pending call records for the session
+    - `session.created`: Adds new task IDs to pending managed set
+    - `session.idle` / `session.status` (idle): Reconciles injected terminal jobs for the parent session (backstop path)
+    - `session.status` (busy): Marks sessions as running from live session state
+    - `session.deleted`: Clears job state, child jobs, and pending call records for the session
 
 6. **Human-in-the-loop Waits**
    - `wait_for_user` calls the facade's `beginUserWait()` only after tool validation

+ 6 - 3
src/hooks/task-session-manager/event-router.ts

@@ -8,7 +8,10 @@
 import type { BackgroundJobStore } from '../../utils/background-job-store';
 import { log } from '../../utils/logger';
 import { isFailoverError } from '../foreground-fallback/index';
-import type { RetainedBoardSnapshotState } from './board-injection';
+import type {
+  InjectedTerminalJobs,
+  RetainedBoardSnapshotState,
+} from './board-injection';
 import type { PendingTaskCall } from './pending-call-tracker';
 
 export async function handleEvent(
@@ -74,7 +77,7 @@ export async function handleEvent(
       clearSession(sessionID: string): void;
       prune(board: { taskIDs(): Set<string> }): void;
     };
-    terminalJobsInjectedByParent: Map<string, Set<string>>;
+    terminalJobsInjectedByParent: Map<string, InjectedTerminalJobs>;
     retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
   },
 ): Promise<void> {
@@ -171,7 +174,7 @@ export async function handleEvent(
         ? deps.options.shouldManageSession(sessionId)
         : false,
       terminalJobsPending: sessionId
-        ? (deps.terminalJobsInjectedByParent.get(sessionId)?.size ?? 0)
+        ? (deps.terminalJobsInjectedByParent.get(sessionId)?.taskIDs.size ?? 0)
         : 0,
       runningJobForSession: job?.state === 'running' || false,
     });

+ 457 - 11
src/hooks/task-session-manager/index.test.ts

@@ -767,12 +767,6 @@ describe('task-session-manager hook', () => {
 
   test('starts a new checkpoint cache epoch at the snapshot limit', async () => {
     const board = new BackgroundJobBoard();
-    board.registerLaunch({
-      taskID: 'child-1',
-      parentSessionID: 'parent-1',
-      agent: 'explorer',
-      description: 'map hooks',
-    });
     const { hook } = createHook({
       backgroundJobBoard: board,
       strategy: 'checkpoint-compatible',
@@ -780,9 +774,18 @@ describe('task-session-manager hook', () => {
 
     const history: string[] = ['root'];
     for (let turn = 0; turn < 20; turn += 1) {
+      // Register a distinct job for each turn
+      const taskID = `child-${turn}`;
+      board.registerLaunch({
+        taskID,
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: `map hooks turn ${turn}`,
+      });
+      // Complete the job immediately
       board.updateStatus({
-        taskID: 'child-1',
-        state: turn % 2 === 0 ? 'completed' : 'error',
+        taskID,
+        state: 'completed',
         resultSummary: `result-${turn}`,
       });
       history.push(`turn-${turn}`);
@@ -797,8 +800,15 @@ describe('task-session-manager hook', () => {
     }
 
     history.push('epoch-2-turn-1');
+    // Register and complete first job in epoch 2
+    board.registerLaunch({
+      taskID: 'child-20',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks epoch 2 turn 1',
+    });
     board.updateStatus({
-      taskID: 'child-1',
+      taskID: 'child-20',
       state: 'completed',
       resultSummary: 'epoch-2-result-1',
     });
@@ -808,9 +818,16 @@ describe('task-session-manager hook', () => {
     expect(boardSnapshotIDs(epochStart)[0]).toEndWith(':20');
 
     history.push('epoch-2-turn-2');
+    // Register and complete second job in epoch 2
+    board.registerLaunch({
+      taskID: 'child-21',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks epoch 2 turn 2',
+    });
     board.updateStatus({
-      taskID: 'child-1',
-      state: 'error',
+      taskID: 'child-21',
+      state: 'completed',
       resultSummary: 'epoch-2-result-2',
     });
     const secondEpochRequest = createAnchoredMessages('parent-1', history);
@@ -2091,6 +2108,435 @@ describe('task-session-manager hook', () => {
     });
   });
 
+  test('reconciles a surfaced terminal job on the next request while wait_for_user is latched', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'approved',
+    });
+
+    // Request 1: inject the board with the completed job
+    const request1 = createMessages('parent-1', 'continue');
+    await transformMessages(hook, request1);
+    expect(boardText(request1)).toContain(
+      'ora-1 / child-1 / oracle / completed, unreconciled',
+    );
+    expect(boardText(request1)).toContain('Result: approved');
+
+    // Latch wait_for_user (simulating the tool call)
+    hook.beginUserWait('parent-1');
+
+    // Request 2: same history + one assistant message with a tool part
+    // (simulating the wait_for_user tool call turn)
+    const request2 = {
+      messages: [
+        ...request1.messages,
+        {
+          info: {
+            role: 'assistant',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [
+            { type: 'text', text: 'calling wait_for_user' },
+            {
+              type: 'tool',
+              tool: 'wait_for_user',
+              id: 'wait-call-1',
+              args: { prompt: 'waiting' },
+            },
+          ],
+        },
+      ],
+    };
+    await transformMessages(hook, request2);
+
+    // The job should now be reconciled (not unreconciled)
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    // The board should no longer show the Result line
+    expect(boardText(request2)).toContain(
+      'ora-1 / child-1 / oracle / completed, reconciled',
+    );
+    expect(boardText(request2)).not.toContain('Result: approved');
+  });
+
+  test('does not reconcile when the same request is transformed twice', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'approved',
+    });
+
+    const messages = createMessages('parent-1', 'continue');
+
+    // Transform the same message array twice (simulating a provider
+    // retry). The second transform strips the previously-injected trailing
+    // board message automatically, then computes the same shape key.
+    await transformMessages(hook, messages);
+    const firstBoardText = boardText(messages);
+
+    await transformMessages(hook, messages);
+    const secondBoardText = boardText(messages);
+
+    // Both should show unreconciled (same prompt shape = no reconciliation)
+    expect(firstBoardText).toContain('completed, unreconciled');
+    expect(secondBoardText).toContain('completed, unreconciled');
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+    });
+  });
+
+  test('reconciles when compaction preserves message and part counts', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'approved',
+    });
+
+    const surfacedRequest = {
+      messages: [
+        {
+          info: {
+            id: 'user-1',
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ type: 'text', text: 'original user turn' }],
+        },
+        {
+          info: {
+            id: 'assistant-1',
+            role: 'assistant',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ type: 'text', text: 'older assistant content' }],
+        },
+      ],
+    };
+    const surfacedMessageCount = surfacedRequest.messages.length;
+    const surfacedPartCount = surfacedRequest.messages.flatMap(
+      (message) => message.parts,
+    ).length;
+
+    await transformMessages(hook, surfacedRequest);
+    expect(boardText(surfacedRequest)).toContain('completed, unreconciled');
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+    });
+
+    const compactedWithNewTurn = {
+      messages: [
+        {
+          info: {
+            id: 'user-1',
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ type: 'text', text: 'original user turn' }],
+        },
+        {
+          info: {
+            id: 'assistant-2',
+            role: 'assistant',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [
+            { type: 'text', text: 'new assistant turn after compaction' },
+          ],
+        },
+      ],
+    };
+
+    expect(compactedWithNewTurn.messages).toHaveLength(surfacedMessageCount);
+    expect(
+      compactedWithNewTurn.messages.flatMap((message) => message.parts),
+    ).toHaveLength(surfacedPartCount);
+
+    await transformMessages(hook, compactedWithNewTurn);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    expect(boardText(compactedWithNewTurn)).toContain('completed, reconciled');
+    expect(boardText(compactedWithNewTurn)).not.toContain('Result: approved');
+  });
+
+  test('stops re-announcing a completion across a run of requests', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'approved',
+    });
+
+    // Build 5 explicit requests: the first carries only the user message,
+    // each subsequent one adds exactly one more assistant part. This makes
+    // it obvious which request is the "model has now reacted" boundary.
+    const userMessage = {
+      info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+      parts: [{ type: 'text', text: 'turn 1' }],
+    };
+    function buildRequest(assistantMessageCount: number) {
+      return {
+        messages: [
+          userMessage,
+          ...Array.from({ length: assistantMessageCount }, (_, i) => ({
+            info: {
+              role: 'assistant',
+              agent: 'orchestrator',
+              sessionID: 'parent-1',
+            },
+            parts: [{ type: 'text', text: `response ${i + 1}` }],
+          })),
+        ],
+      };
+    }
+
+    const resultLines: boolean[] = [];
+    for (let i = 0; i < 5; i += 1) {
+      const request = buildRequest(i);
+      await transformMessages(hook, request);
+      const board_text = boardText(request);
+      resultLines.push(board_text?.includes('Result: approved') ?? false);
+    }
+
+    // The Result line should appear in exactly one board — the first one,
+    // where the shape key was first stored. Every later request carries a
+    // strictly larger shape, so the completion is reconciled.
+    const resultCount = resultLines.filter((x) => x).length;
+    expect(resultCount).toBe(1);
+    expect(resultLines[0]).toBe(true);
+    expect(resultLines[1]).toBe(false);
+    expect(resultLines[2]).toBe(false);
+    expect(resultLines[3]).toBe(false);
+    expect(resultLines[4]).toBe(false);
+  });
+
+  test('reconciles a surfaced terminal job on the next request in checkpoint-compatible mode', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+    });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'approved',
+    });
+
+    // Request 1: inject the board with the completed job
+    const request1 = createAnchoredMessages('parent-1', ['turn 1']);
+    await transformMessages(hook, request1);
+    const snapshots1 = boardSnapshotIDs(request1);
+    expect(snapshots1.length).toBeGreaterThan(0);
+    expect(boardText(request1)).toContain('completed, unreconciled');
+
+    // Request 2: same history + one assistant message
+    const request2 = {
+      messages: [
+        ...request1.messages,
+        {
+          info: {
+            role: 'assistant',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ type: 'text', text: 'response 1' }],
+        },
+      ],
+    };
+    await transformMessages(hook, request2);
+
+    // The job should now be reconciled
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    // A new snapshot should be created reflecting the reconciled state
+    const snapshots2 = boardSnapshotIDs(request2);
+    expect(snapshots2.length).toBeGreaterThan(snapshots1.length);
+    expect(boardText(request2)).toContain('completed, reconciled');
+  });
+
+  test('reconciles all terminal jobs surfaced on the same prompt shape', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    // Two completions that arrive in the same request window
+    board.registerLaunch({
+      taskID: 'child-A',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan A',
+    });
+    board.registerLaunch({
+      taskID: 'child-B',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan B',
+    });
+    board.updateStatus({
+      taskID: 'child-A',
+      state: 'completed',
+      resultSummary: 'approved A',
+    });
+    board.updateStatus({
+      taskID: 'child-B',
+      state: 'completed',
+      resultSummary: 'approved B',
+    });
+
+    // Request 1: both jobs are surfaced and stored under the same prompt
+    // shape key. The union branch of rememberInjectedTerminalJobs keeps
+    // them together even if a second completion lands mid-request.
+    const request1 = createMessages('parent-1', 'continue');
+    await transformMessages(hook, request1);
+    expect(boardText(request1)).toContain('Result: approved A');
+    expect(boardText(request1)).toContain('Result: approved B');
+
+    // Request 2: same shape (no new part) — both jobs stay unreconciled
+    const request2 = createMessages('parent-1', 'continue');
+    await transformMessages(hook, request2);
+    expect(boardText(request2)).toContain('completed, unreconciled');
+    expect(board.get('child-A')).toMatchObject({ terminalUnreconciled: true });
+    expect(board.get('child-B')).toMatchObject({ terminalUnreconciled: true });
+
+    // Request 3: model added an assistant part — both jobs reconcile
+    // together because they share a stored prompt shape key.
+    const request3 = {
+      messages: [
+        ...request1.messages,
+        {
+          info: {
+            role: 'assistant',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ type: 'text', text: 'acknowledged' }],
+        },
+      ],
+    };
+    await transformMessages(hook, request3);
+
+    expect(board.get('child-A')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    expect(board.get('child-B')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('idle backstop is a no-op after shape-reconciliation already fired', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      idleReconcileDelayMs: 0,
+    });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'approved',
+    });
+
+    // Request 1: surface the completion
+    const request1 = createMessages('parent-1', 'continue');
+    await transformMessages(hook, request1);
+    expect(board.get('child-1')).toMatchObject({ terminalUnreconciled: true });
+
+    // Request 2: model added a part, shape-reconciliation fires
+    const request2 = {
+      messages: [
+        ...request1.messages,
+        {
+          info: {
+            role: 'assistant',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ type: 'text', text: 'thanks' }],
+        },
+      ],
+    };
+    await transformMessages(hook, request2);
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+
+    // Session goes idle — the backstop fires but must not disturb the
+    // already-reconciled job (and must not error on a missing entry).
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushChildIdleReconcile();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+  });
+
   test('preserves injected terminal jobs for recoverable HTTP 400 errors', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });

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

@@ -9,6 +9,7 @@ import type { SessionLifecycle } from '../session-lifecycle';
 import { isUserMessageWithParts } from '../types';
 import {
   BACKGROUND_JOB_BOARD_METADATA_KEY,
+  type InjectedTerminalJobs,
   type InjectionState,
   injectBackgroundJobBoard,
   MAX_PROCESSED_INJECTED_COMPLETIONS,
@@ -83,7 +84,7 @@ export function createTaskSessionManagerHook(
 
   const processedInjectedCompletions = new Set<string>();
   const processedInjectedCompletionOrder: string[] = [];
-  const terminalJobsInjectedByParent = new Map<string, Set<string>>();
+  const terminalJobsInjectedByParent = new Map<string, InjectedTerminalJobs>();
 
   // Forward refs for circular deps — set after corresponding managers exist.
   // These are captured by closure in createIdleReconciler and only called