Browse Source

fix: keep pending executions out of shape reconciliation

Alvin Unreal 1 week ago
parent
commit
f6e564ffd2

+ 28 - 14
src/hooks/task-session-manager/board-injection.ts

@@ -312,6 +312,25 @@ function rememberPendingInjectedTerminalJob(
   state.pendingInjectedTerminalJobsByParent.set(parentSessionID, pending);
   state.pendingInjectedTerminalJobsByParent.set(parentSessionID, pending);
 }
 }
 
 
+function reconcileExecutionBatch(
+  state: InjectionState,
+  parentSessionID: string,
+  executions: Iterable<BackgroundJobExecution>,
+): void {
+  for (const execution of executions) {
+    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);
+  }
+}
+
 export function rememberInjectedTerminalJobs(
 export function rememberInjectedTerminalJobs(
   state: InjectionState,
   state: InjectionState,
   parentSessionID: string,
   parentSessionID: string,
@@ -384,18 +403,7 @@ export function reconcileInjectedTerminalJobs(
     executions: [...executions.values()],
     executions: [...executions.values()],
   });
   });
 
 
-  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);
-  }
+  reconcileExecutionBatch(state, parentSessionID, executions.values());
   state.terminalJobsInjectedByParent.delete(parentSessionID);
   state.terminalJobsInjectedByParent.delete(parentSessionID);
   state.pendingInjectedTerminalJobsByParent.delete(parentSessionID);
   state.pendingInjectedTerminalJobsByParent.delete(parentSessionID);
 }
 }
@@ -408,8 +416,14 @@ function reconcileConsumedTerminalJobs(
   const entry = state.terminalJobsInjectedByParent.get(parentSessionID);
   const entry = state.terminalJobsInjectedByParent.get(parentSessionID);
   if (!entry || 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.
-  reconcileInjectedTerminalJobs(state, parentSessionID);
+  // these completions, so it has consumed that shaped delivery. Pending
+  // synthetic completions belong to a later delivery and remain pending.
+  log('[task-session-manager] reconciling consumed terminal jobs', {
+    parentSessionID,
+    executions: [...entry.executions.values()],
+  });
+  reconcileExecutionBatch(state, parentSessionID, entry.executions.values());
+  state.terminalJobsInjectedByParent.delete(parentSessionID);
 }
 }
 
 
 export async function injectBackgroundJobBoard(
 export async function injectBackgroundJobBoard(

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

@@ -1545,6 +1545,84 @@ describe('task-session-manager hook', () => {
     }
     }
   });
   });
 
 
+  test('shape reconciliation leaves a pending synthetic completion unreconciled until its payload is delivered', async () => {
+    const board = new BackgroundJobBoard({ maxReusablePerAgent: 3 });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      idleReconcileDelayMs: 0,
+    });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'first',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'completed' });
+    await transformMessages(hook, createMessages('parent-1', 'first turn'));
+
+    board.registerLaunch({
+      taskID: 'child-2',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'second',
+    });
+    const pendingCompletion = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              id: 'child-2-completion',
+              synthetic: true,
+              text: [
+                '<task id="child-2" state="completed">',
+                '<summary>Background task completed: child-2</summary>',
+                '<task_result>done2</task_result>',
+                '</task>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+    await hook['experimental.chat.messages.transform'](
+      {},
+      pendingCompletion as never,
+    );
+    expect(board.get('child-2')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+    });
+
+    // Shape reconciliation runs before this current payload is rendered.
+    await hook.injectBackgroundJobBoard({}, pendingCompletion as never);
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    expect(board.get('child-2')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+    });
+    expect(boardText(pendingCompletion)).toContain(
+      'child-2 / oracle / completed, unreconciled',
+    );
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    await flushChildIdleReconcile();
+    expect(board.get('child-2')).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 });
@@ -1809,6 +1887,7 @@ describe('task-session-manager hook', () => {
       state: 'reconciled',
       state: 'reconciled',
     });
     });
 
 
+    board.drop('child-1');
     board.registerLaunch({
     board.registerLaunch({
       taskID: 'child-1',
       taskID: 'child-1',
       parentSessionID: 'parent-1',
       parentSessionID: 'parent-1',

+ 1 - 1
src/utils/background-job-board.test.ts

@@ -168,7 +168,7 @@ describe('BackgroundJobBoard', () => {
     expect(metadata?.text).toBe(board.formatForPrompt('parent-1'));
     expect(metadata?.text).toBe(board.formatForPrompt('parent-1'));
     expect(metadata?.terminalUnreconciledTaskIDs).toEqual([
     expect(metadata?.terminalUnreconciledTaskIDs).toEqual([
       { taskID: 'ses_1', generation: 1 },
       { taskID: 'ses_1', generation: 1 },
-      { taskID: 'ses_2', generation: 1 },
+      { taskID: 'ses_2', generation: 2 },
     ]);
     ]);
     expect(
     expect(
       metadata?.terminalUnreconciledTaskIDs.some(
       metadata?.terminalUnreconciledTaskIDs.some(

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

@@ -104,6 +104,7 @@ const AGENT_PREFIX: Record<string, string> = {
 export class BackgroundJobBoard implements BackgroundJobStore {
 export class BackgroundJobBoard implements BackgroundJobStore {
   private readonly jobs = new Map<string, BackgroundJobRecord>();
   private readonly jobs = new Map<string, BackgroundJobRecord>();
   private readonly counters = new Map<string, number>();
   private readonly counters = new Map<string, number>();
+  private executionSequence = 0;
   private terminalStateListeners: TerminalStateListener[] = [];
   private terminalStateListeners: TerminalStateListener[] = [];
 
 
   private readonly maxReusablePerAgent: number;
   private readonly maxReusablePerAgent: number;
@@ -150,12 +151,13 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
 
   registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord {
   registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord {
     const now = input.now ?? Date.now();
     const now = input.now ?? Date.now();
+    const generation = ++this.executionSequence;
     const existing = this.jobs.get(input.taskID);
     const existing = this.jobs.get(input.taskID);
 
 
     if (existing) {
     if (existing) {
       const updated = {
       const updated = {
         ...existing,
         ...existing,
-        generation: existing.generation + 1,
+        generation,
         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,
@@ -182,7 +184,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
 
     const record: BackgroundJobRecord = {
     const record: BackgroundJobRecord = {
       taskID: input.taskID,
       taskID: input.taskID,
-      generation: 1,
+      generation,
       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`,