Преглед изворни кода

fix: scope reconciliation to delivered jobs

Parent-level gating preserved sibling isolation but starved terminal jobs rendered before idle. Carry terminal IDs alongside board payloads and checkpoint snapshots, then reconcile the exact delivered union.\n\nThis supersedes broad parent scanning and parent-level gating.\n\nRefs #915
bruce Mead пре 3 недеља
родитељ
комит
fc99b484cd

+ 48 - 53
src/hooks/task-session-manager/board-injection.ts

@@ -48,6 +48,7 @@ type RetainedBoardSnapshot = {
   anchorKey: string;
   id: string;
   text: string;
+  terminalUnreconciledTaskIDs: string[];
 };
 
 export type RetainedBoardSnapshotState = {
@@ -214,11 +215,9 @@ export function updateFromInjectedCompletion(
     });
     rememberProcessedInjectedCompletion(state, occurrenceId);
     if (existing?.terminalUnreconciled && existing?.parentSessionID) {
-      rememberInjectedTerminalJob(
-        state,
-        existing.parentSessionID,
+      rememberInjectedTerminalJobs(state, existing.parentSessionID, [
         existing.taskID,
-      );
+      ]);
     }
     return existing;
   }
@@ -236,7 +235,9 @@ export function updateFromInjectedCompletion(
   if (!updated) return undefined;
 
   if (updated.terminalUnreconciled && updated.parentSessionID) {
-    rememberInjectedTerminalJob(state, updated.parentSessionID, updated.taskID);
+    rememberInjectedTerminalJobs(state, updated.parentSessionID, [
+      updated.taskID,
+    ]);
   }
 
   log('[task-session-manager] processed injected background completion', {
@@ -277,49 +278,31 @@ export function isMissingRememberedSessionError(output: string): boolean {
   );
 }
 
-function rememberInjectedTerminalJob(
+export function rememberInjectedTerminalJobs(
   state: InjectionState,
   parentSessionID: string,
-  taskID: string,
+  taskIDs: string[],
 ): void {
-  if (!parentSessionID || !taskID) return;
+  if (!parentSessionID || !taskIDs || taskIDs.length === 0) return;
 
   const existing =
     state.terminalJobsInjectedByParent.get(parentSessionID) ??
     new Set<string>();
-  if (existing.has(taskID)) return;
+  let changed = false;
+  for (const taskID of taskIDs) {
+    if (!existing.has(taskID)) {
+      existing.add(taskID);
+      changed = true;
+    }
+  }
+  if (!changed) return;
 
-  existing.add(taskID);
   state.terminalJobsInjectedByParent.set(parentSessionID, existing);
 
-  log('[task-session-manager] terminal job injected for reconciliation', {
-    parentSessionID,
-    taskID,
-  });
-}
-
-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,
+    taskIDs: [...existing],
   });
-
-  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(
@@ -376,19 +359,23 @@ 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;
 
-    const parentID = message.info.sessionID;
-    if (!state.terminalJobsInjectedByParent.has(parentID)) {
-      rememberInjectedTerminalJobs(state, parentID);
+    const boardMeta = state.backgroundJobBoard.formatForPromptWithMetadata(
+      message.info.sessionID,
+    );
+    const reminder = boardMeta?.text;
+    if (!reminder) return;
+
+    if (boardMeta?.terminalUnreconciledTaskIDs?.length) {
+      rememberInjectedTerminalJobs(
+        state,
+        message.info.sessionID,
+        boardMeta.terminalUnreconciledTaskIDs,
+      );
     }
     // Append the board as its own trailing message rather than mutating
     // an existing user message. In long tool loops the latest user
@@ -426,7 +413,9 @@ function injectCheckpointBoard(
     (message) =>
       isUserMessageWithParts(message) && message.info.sessionID === sessionID,
   );
-  const reminder = state.backgroundJobBoard.formatForPrompt(sessionID);
+  const boardMeta =
+    state.backgroundJobBoard.formatForPromptWithMetadata(sessionID);
+  const reminder = boardMeta?.text;
   const textPart = triggeringMessage?.parts.find(
     (part) => part.type === 'text' && typeof part.text === 'string',
   );
@@ -459,20 +448,22 @@ function injectCheckpointBoard(
         anchorKey,
         id: `oh-my-opencode-slim:background-job-board:${encodedSessionID}:${sequence}`,
         text: reminder,
+        terminalUnreconciledTaskIDs:
+          boardMeta?.terminalUnreconciledTaskIDs ?? [],
       });
     }
-    if (!state.terminalJobsInjectedByParent.has(sessionID)) {
-      rememberInjectedTerminalJobs(state, sessionID);
-    }
   }
 
-  replayCheckpointBoard(
+  const replayedIDs = replayCheckpointBoard(
     messages,
     replayBaseMessage,
     sessionID,
     snapshotState,
     state.metadataKey,
   );
+  if (replayedIDs.length > 0) {
+    rememberInjectedTerminalJobs(state, sessionID, replayedIDs);
+  }
 }
 
 function findLastMessageAnchorKey(
@@ -592,7 +583,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[]>();
@@ -609,6 +600,7 @@ function replayBoardSnapshots(
   );
 
   const rebuiltMessages: unknown[] = [];
+  const replayedIDs: string[] = [];
   let realMessageIndex = 0;
   for (const message of messages) {
     rebuiltMessages.push(message);
@@ -630,10 +622,14 @@ function replayBoardSnapshots(
           usedMessageIDs,
         ),
       );
+      if (snapshot.terminalUnreconciledTaskIDs?.length) {
+        replayedIDs.push(...snapshot.terminalUnreconciledTaskIDs);
+      }
     }
   }
 
   messages.splice(0, messages.length, ...rebuiltMessages);
+  return replayedIDs;
 }
 
 function replayCheckpointBoard(
@@ -642,15 +638,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;
 }

+ 238 - 13
src/hooks/task-session-manager/index.test.ts

@@ -1390,7 +1390,7 @@ describe('task-session-manager hook', () => {
       { output: ['task_id: child-1', 'state: running'].join('\n') },
     );
 
-    // setup sibling child-2 (will be terminal via updateStatus, no injected completion)
+    // 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' },
       {
@@ -1402,6 +1402,38 @@ describe('task-session-manager hook', () => {
       { 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',
@@ -1409,10 +1441,40 @@ describe('task-session-manager hook', () => {
     });
     expect(board.get('child-2')?.terminalUnreconciled).toBe(true);
 
-    // Full production sequence: transformMessages performs the message
-    // transform and injectBackgroundJobBoard. Only child-1 has a synthetic
-    // terminal result; child-2 is terminal through updateStatus only.
-    const messages = {
+    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' },
@@ -1432,17 +1494,99 @@ describe('task-session-manager hook', () => {
         },
       ],
     };
-    await transformMessages(hook, messages);
-
-    expect(boardText(messages)).toContain('child-2');
-    expect(boardText(messages)).toContain('completed, unreconciled');
+    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);
 
-    // duplicate full production injection stays idempotent (narrow + gated broad)
+    // 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);
-    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
-    expect(board.get('child-2')?.terminalUnreconciled).toBe(true);
+
+    const emitted = boardText(messages);
+    expect(emitted).not.toContain('child-2');
 
     await hook.event({
       event: {
@@ -1456,13 +1600,94 @@ describe('task-session-manager hook', () => {
       state: 'reconciled',
       terminalUnreconciled: false,
     });
-    // sibling not remembered via narrow; broad must not widen to it
     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 });

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

@@ -483,7 +483,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 active = this.list(parentSessionID).filter(
       (job) => job.state === 'running' || job.terminalUnreconciled,
     );
@@ -491,7 +496,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',
@@ -508,6 +513,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

@@ -228,6 +228,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.