Kaynağa Gözat

Merge pull request #1091 from alvinunreal/omos/issue-1090-repro

fix(tasks): accept legacy task completions
Alvin 3 hafta önce
ebeveyn
işleme
0616ab71e3

+ 254 - 64
src/hooks/task-session-manager/board-injection.ts

@@ -120,6 +120,13 @@ type SyntheticTerminalOccurrenceLookup =
       reason: string;
     };
 
+type SyntheticTerminalProvenanceKind =
+  | 'explicit'
+  | 'host-message'
+  | 'legacy';
+
+const HOST_MESSAGE_OCCURRENCE_PREFIX = 'host-message:';
+
 export interface InjectionState {
   backgroundJobBoard: BackgroundJobStore;
   lifecycleLedger: BackgroundJobLifecycleLedger;
@@ -217,26 +224,47 @@ function createOccurrenceId(
     return explicitOccurrenceID;
   }
 
-  if (typeof message.info.id === 'string') {
-    return `${message.info.id}:${partIndex}`;
-  }
-
   const sessionID = message.info.sessionID ?? 'unknown';
   const content = typeof part.text === 'string' ? part.text : '';
 
   const status = parseTaskStatusOutput(content);
   if (status) {
+    const messageID = getCanonicalHostMessageID(part, message);
+    if (messageID) {
+      return hostMessageOccurrenceID(messageID, status.taskID, content);
+    }
+
+    if (typeof message.info.id === 'string') {
+      return `${message.info.id}:${partIndex}`;
+    }
+
     const stableKey = `${sessionID}:${status.taskID}:${status.state}:${status.result ?? ''}`;
     const hash = djb2Hash(stableKey);
     return `anon:${hash}`;
   }
 
+  if (typeof message.info.id === 'string') {
+    return `${message.info.id}:${partIndex}`;
+  }
+
   const hash = djb2Hash(`${sessionID}:${content}`);
   return `anon:${hash}`;
 }
 
-function injectedCompletionKey(taskID: string, occurrenceId: string): string {
-  return `${taskID}\u001f${occurrenceId}`;
+function injectedCompletionKey(
+  taskID: string,
+  occurrenceId: string,
+  provenanceKind: SyntheticTerminalProvenanceKind,
+): string {
+  return `${provenanceKind}\u001f${taskID}\u001f${occurrenceId}`;
+}
+
+function hasProvenanceKind(
+  key: string,
+  taskID: string,
+  provenanceKind: SyntheticTerminalProvenanceKind,
+): boolean {
+  return key.startsWith(`${provenanceKind}\u001f${taskID}\u001f`);
 }
 
 function getExplicitOccurrenceID(part: MessagePart): string | undefined {
@@ -247,6 +275,42 @@ function getExplicitOccurrenceID(part: MessagePart): string | undefined {
   return undefined;
 }
 
+function getHostMessageID(part: MessagePart): string | undefined {
+  return typeof part.messageID === 'string' && part.messageID.trim() !== ''
+    ? part.messageID
+    : undefined;
+}
+
+function getCanonicalHostMessageID(
+  part: MessagePart,
+  message?: MessageWithParts,
+): string | undefined {
+  return (
+    getHostMessageID(part) ??
+    (typeof message?.info.id === 'string' && message.info.id.trim() !== ''
+      ? message.info.id
+      : undefined)
+  );
+}
+
+function hostMessageOccurrenceID(
+  messageID: string,
+  taskID: string,
+  terminalPayload: string,
+): string {
+  return `${HOST_MESSAGE_OCCURRENCE_PREFIX}${djb2Hash(
+    `${messageID}:${taskID}`,
+  )}:${sha256Hash(terminalPayload)}`;
+}
+
+function provenanceKindForPart(
+  part: MessagePart,
+  message?: MessageWithParts,
+): SyntheticTerminalProvenanceKind {
+  if (getExplicitOccurrenceID(part)) return 'explicit';
+  return getCanonicalHostMessageID(part, message) ? 'host-message' : 'legacy';
+}
+
 function isProcessableSyntheticTerminal(
   text: string,
   status: TaskStatusOutput,
@@ -276,11 +340,21 @@ function observationOccurrenceID(
     return { occurrenceID: explicitOccurrenceID, reliable: true };
   }
 
-  const messageID =
-    typeof part.messageID === 'string' ? part.messageID : 'unknown-message';
+  const messageID = getHostMessageID(part);
+  if (messageID) {
+    return {
+      occurrenceID: hostMessageOccurrenceID(
+        messageID,
+        status.taskID,
+        part.text ?? '',
+      ),
+      reliable: true,
+    };
+  }
+
   return {
     occurrenceID: `ambiguous:${djb2Hash(
-      `${messageID}:${status.taskID}:${part.text ?? ''}`,
+      `unknown-message:${status.taskID}:${part.text ?? ''}`,
     )}`,
     reliable: false,
   };
@@ -289,12 +363,17 @@ function observationOccurrenceID(
 function rememberSyntheticTerminalOccurrence(
   state: InjectionState,
   origin: SyntheticTerminalOccurrenceOrigin,
+  provenanceKind: SyntheticTerminalProvenanceKind,
 ): void {
   const occurrences = state.syntheticTerminalOccurrences;
   const order = state.syntheticTerminalOccurrenceOrder;
   if (!occurrences || !order) return;
 
-  const key = injectedCompletionKey(origin.taskID, origin.occurrenceID);
+  const key = injectedCompletionKey(
+    origin.taskID,
+    origin.occurrenceID,
+    provenanceKind,
+  );
   const existing = occurrences.get(key);
   if (existing) {
     // The first observation owns the lifecycle provenance. A later event for
@@ -315,28 +394,64 @@ function findSyntheticTerminalOccurrence(
   state: InjectionState,
   taskID: string,
   occurrenceID: string,
+  provenanceKind: SyntheticTerminalProvenanceKind,
+  explicitOccurrenceID: boolean,
+  currentGeneration: number | undefined,
+  currentTaskGeneration: number | undefined,
+  deletionEpoch: number | undefined,
 ): SyntheticTerminalOccurrenceLookup | undefined {
   const occurrences = state.syntheticTerminalOccurrences;
   if (!occurrences) return undefined;
 
-  const direct = occurrences.get(injectedCompletionKey(taskID, occurrenceID));
-  if (direct) return { kind: 'matched', origin: direct };
-
-  // Older/runtime-derived parts may lack an id in the transform payload even
-  // though the event hook saw the same terminal text. Only use an ambiguous
-  // fallback when there is exactly one candidate for this task. Multiple
-  // candidates cannot establish ownership and must remain fail-closed.
-  const candidates = [...occurrences.values()].filter(
-    (origin) =>
-      origin.taskID === taskID && origin.occurrenceID.startsWith('ambiguous:'),
+  const direct = occurrences.get(
+    injectedCompletionKey(taskID, occurrenceID, provenanceKind),
   );
-  if (candidates.length === 1) {
-    return { kind: 'matched', origin: candidates[0] };
+  if (explicitOccurrenceID) {
+    return direct ? { kind: 'matched', origin: direct } : undefined;
+  }
+  if (direct && provenanceKind !== 'host-message') {
+    return { kind: 'matched', origin: direct };
+  }
+
+  if (provenanceKind === 'host-message') {
+    // Host message ids are weaker than part ids. Restrict them to the first
+    // generation: unrelated tasks do not advance this task-local run proof.
+    if (deletionEpoch !== undefined || currentTaskGeneration !== 1) {
+      return direct ? { kind: 'matched', origin: direct } : undefined;
+    }
+
+    const candidates = [...occurrences.entries()]
+      .filter(
+        ([key, origin]) =>
+          hasProvenanceKind(key, taskID, 'host-message') &&
+          origin.taskID === taskID &&
+          origin.generationAtObservation === currentGeneration,
+      )
+      .map(([, origin]) => origin);
+    if (candidates.length === 1 && candidates[0].occurrenceID === occurrenceID) {
+      return { kind: 'matched', origin: candidates[0] };
+    }
+    if (candidates.length > 1) {
+      return {
+        kind: 'ambiguous',
+        reason: `multiple current-generation host message.part.updated origins (${candidates.length}) could not be uniquely matched`,
+      };
+    }
+
+    return direct ? { kind: 'matched', origin: direct } : undefined;
   }
-  if (candidates.length > 1) {
+
+  // Older/runtime-derived parts may lack an id in the transform payload. Do
+  // not guess ownership from an ambiguous event; the legacy path is allowed to
+  // fail closed, but it may not manufacture a match from candidate count.
+  const legacyCandidates = [...occurrences.entries()].filter(
+    ([key, origin]) =>
+      hasProvenanceKind(key, taskID, 'legacy') && origin.taskID === taskID,
+  );
+  if (legacyCandidates.length > 0) {
     return {
       kind: 'ambiguous',
-      reason: `multiple ambiguous message.part.updated origins (${candidates.length}) could not be uniquely matched`,
+      reason: 'legacy weak provenance did not match a canonical host identity',
     };
   }
   return undefined;
@@ -346,6 +461,7 @@ function rememberProcessedSyntheticTerminal(
   state: InjectionState,
   taskID: string,
   occurrenceID: string,
+  provenanceKind: SyntheticTerminalProvenanceKind,
   origin: SyntheticTerminalOccurrenceOrigin | undefined,
   generation: number | undefined,
 ): void {
@@ -354,29 +470,38 @@ function rememberProcessedSyntheticTerminal(
     return;
   }
 
-  rememberSyntheticTerminalOccurrence(state, {
-    taskID,
-    occurrenceID,
-    generationAtObservation: generation,
-    lifecycleEpochAtObservation: state.getLifecycleEpoch?.() ?? 0,
-    phase: 'processed',
-  });
+  rememberSyntheticTerminalOccurrence(
+    state,
+    {
+      taskID,
+      occurrenceID,
+      generationAtObservation: generation,
+      lifecycleEpochAtObservation: state.getLifecycleEpoch?.() ?? 0,
+      phase: 'processed',
+    },
+    provenanceKind,
+  );
 }
 
 function failClosedSyntheticTerminal(
   state: InjectionState,
   status: TaskStatusOutput,
   occurrenceID: string,
+  provenanceKind: SyntheticTerminalProvenanceKind,
   existing: BackgroundJobRecord | undefined,
   reason: string,
 ): void {
-  rememberSyntheticTerminalOccurrence(state, {
-    taskID: status.taskID,
-    occurrenceID,
-    generationAtObservation: existing?.generation,
-    lifecycleEpochAtObservation: state.getLifecycleEpoch?.() ?? 0,
-    phase: 'ambiguous',
-  });
+  rememberSyntheticTerminalOccurrence(
+    state,
+    {
+      taskID: status.taskID,
+      occurrenceID,
+      generationAtObservation: existing?.generation,
+      lifecycleEpochAtObservation: state.getLifecycleEpoch?.() ?? 0,
+      phase: 'ambiguous',
+    },
+    provenanceKind,
+  );
 
   if (existing?.state === 'running') {
     markSyntheticTerminalUncertain(
@@ -427,10 +552,15 @@ export function observeSyntheticTerminalPart(
   if (!status || !isProcessableSyntheticTerminal(part.text, status)) return;
 
   const { occurrenceID, reliable } = observationOccurrenceID(part, status);
+  const provenanceKind = provenanceKindForPart(part);
   const existing = state.backgroundJobBoard.get(status.taskID);
   const lifecycleEpoch = state.getLifecycleEpoch?.() ?? 0;
   const deletionEpoch = state.getDeletionEpoch?.(status.taskID);
-  const occurrenceKey = injectedCompletionKey(status.taskID, occurrenceID);
+  const occurrenceKey = injectedCompletionKey(
+    status.taskID,
+    occurrenceID,
+    provenanceKind,
+  );
   const priorOccurrence =
     state.syntheticTerminalOccurrences?.get(occurrenceKey);
   const hasPreDeletionProvenance =
@@ -438,28 +568,38 @@ export function observeSyntheticTerminalPart(
     (priorOccurrence !== undefined &&
       priorOccurrence.lifecycleEpochAtObservation < deletionEpoch &&
       priorOccurrence.phase !== 'ambiguous');
+  const hostMessageProvenance = provenanceKind === 'host-message';
   const phase: SyntheticTerminalOccurrencePhase =
-    !reliable || !existing || !hasPreDeletionProvenance
+    !reliable ||
+    !existing ||
+    (hostMessageProvenance
+      ? deletionEpoch !== undefined || existing.taskGeneration !== 1
+      : !hasPreDeletionProvenance)
       ? 'ambiguous'
       : 'observed';
 
-  rememberSyntheticTerminalOccurrence(state, {
-    taskID: status.taskID,
-    occurrenceID,
-    generationAtObservation: existing?.generation,
-    lifecycleEpochAtObservation: lifecycleEpoch,
-    phase,
-  });
+  rememberSyntheticTerminalOccurrence(
+    state,
+    {
+      taskID: status.taskID,
+      occurrenceID,
+      generationAtObservation: existing?.generation,
+      lifecycleEpochAtObservation: lifecycleEpoch,
+      phase,
+    },
+    provenanceKind,
+  );
 }
 
 function hasRememberedInjectedCompletion(
   state: InjectionState,
   taskID: string,
   occurrenceId: string,
+  provenanceKind: SyntheticTerminalProvenanceKind,
   currentGeneration: number | undefined,
 ): boolean {
   const fence = state.injectedCompletionFences?.get(
-    injectedCompletionKey(taskID, occurrenceId),
+    injectedCompletionKey(taskID, occurrenceId, provenanceKind),
   );
   if (!fence) return false;
 
@@ -487,14 +627,16 @@ function hasRememberedInjectedCompletion(
 function rememberInjectedCompletionFence(
   state: InjectionState,
   occurrenceId: string,
+  provenanceKind: SyntheticTerminalProvenanceKind,
   fence: InjectedCompletionFence,
 ): void {
   const fences = state.injectedCompletionFences;
   if (!fences) return;
 
-  fences.set(injectedCompletionKey(fence.taskID, occurrenceId), {
-    ...fence,
-  });
+  fences.set(
+    injectedCompletionKey(fence.taskID, occurrenceId, provenanceKind),
+    { ...fence },
+  );
 }
 
 // ── Exported functions ─────────────────────────────────────────────────
@@ -588,22 +730,30 @@ export function updateFromInjectedCompletion(
   if (summary && !isCompleted && !isFailed) return undefined;
 
   const occurrenceId = createOccurrenceId(part, message, partIndex);
+  const provenanceKind = provenanceKindForPart(part, message);
+  const hasExplicitOccurrenceID = provenanceKind === 'explicit';
 
   const existing = state.backgroundJobBoard.get(status.taskID);
+  const deletionEpoch = state.getDeletionEpoch?.(status.taskID);
   const occurrenceLookup = findSyntheticTerminalOccurrence(
     state,
     status.taskID,
     occurrenceId,
+    provenanceKind,
+    hasExplicitOccurrenceID,
+    existing?.generation,
+    existing?.taskGeneration,
+    deletionEpoch,
   );
   const origin =
     occurrenceLookup?.kind === 'matched' ? occurrenceLookup.origin : undefined;
-  const deletionEpoch = state.getDeletionEpoch?.(status.taskID);
 
   if (occurrenceLookup?.kind === 'ambiguous') {
     failClosedSyntheticTerminal(
       state,
       status,
       occurrenceId,
+      provenanceKind,
       existing,
       occurrenceLookup.reason,
     );
@@ -617,12 +767,34 @@ export function updateFromInjectedCompletion(
       state,
       status,
       occurrenceId,
+      provenanceKind,
       existing,
       'message.part.updated origin was permanently ambiguous',
     );
     return undefined;
   }
 
+  if (provenanceKind === 'host-message') {
+    if (
+      !origin ||
+      !existing ||
+      deletionEpoch !== undefined ||
+      origin.phase !== 'observed' ||
+      existing.taskGeneration !== 1 ||
+      origin.generationAtObservation !== existing.generation
+    ) {
+      failClosedSyntheticTerminal(
+        state,
+        status,
+        occurrenceId,
+        provenanceKind,
+        existing,
+        'host messageID provenance did not identify exactly one current origin',
+      );
+      return undefined;
+    }
+  }
+
   if (origin?.phase === 'observed') {
     const generationChanged =
       origin.generationAtObservation !== undefined &&
@@ -659,6 +831,7 @@ export function updateFromInjectedCompletion(
       state,
       status,
       occurrenceId,
+      provenanceKind,
       existing,
       'no message.part.updated origin was observed',
     );
@@ -670,6 +843,7 @@ export function updateFromInjectedCompletion(
       state,
       status.taskID,
       occurrenceId,
+      provenanceKind,
       existing?.generation,
     )
   ) {
@@ -689,15 +863,22 @@ export function updateFromInjectedCompletion(
       terminalState: existing?.terminalState,
       result: status.result,
     });
-    rememberProcessedInjectedCompletion(state, status.taskID, occurrenceId, {
-      taskID: status.taskID,
-      generation: existing?.generation ?? 0,
-      lifecycleEpoch: state.getLifecycleEpoch?.() ?? 0,
-    });
+    rememberProcessedInjectedCompletion(
+      state,
+      status.taskID,
+      occurrenceId,
+      provenanceKind,
+      {
+        taskID: status.taskID,
+        generation: existing?.generation ?? 0,
+        lifecycleEpoch: state.getLifecycleEpoch?.() ?? 0,
+      },
+    );
     rememberProcessedSyntheticTerminal(
       state,
       status.taskID,
       occurrenceId,
+      provenanceKind,
       origin,
       existing?.generation,
     );
@@ -716,6 +897,7 @@ export function updateFromInjectedCompletion(
   const processedOccurrenceKey = injectedCompletionKey(
     status.taskID,
     occurrenceId,
+    provenanceKind,
   );
   if (state.processedInjectedCompletions.has(processedOccurrenceKey)) {
     return undefined;
@@ -747,14 +929,21 @@ export function updateFromInjectedCompletion(
     state,
     updated.taskID,
     occurrenceId,
+    provenanceKind,
     origin,
     updated.generation,
   );
-  rememberProcessedInjectedCompletion(state, status.taskID, occurrenceId, {
-    taskID: updated.taskID,
-    generation: updated.generation,
-    lifecycleEpoch: state.getLifecycleEpoch?.() ?? 0,
-  });
+  rememberProcessedInjectedCompletion(
+    state,
+    status.taskID,
+    occurrenceId,
+    provenanceKind,
+    {
+      taskID: updated.taskID,
+      generation: updated.generation,
+      lifecycleEpoch: state.getLifecycleEpoch?.() ?? 0,
+    },
+  );
   return updated;
 }
 
@@ -762,14 +951,15 @@ export function rememberProcessedInjectedCompletion(
   state: InjectionState,
   taskID: string,
   occurrenceId: string,
+  provenanceKind: SyntheticTerminalProvenanceKind,
   fence?: InjectedCompletionFence,
 ): void {
-  const signature = injectedCompletionKey(taskID, occurrenceId);
+  const signature = injectedCompletionKey(taskID, occurrenceId, provenanceKind);
   state.processedInjectedCompletions.add(signature);
   state.processedInjectedCompletionOrder.push(signature);
 
   if (fence) {
-    rememberInjectedCompletionFence(state, occurrenceId, fence);
+    rememberInjectedCompletionFence(state, occurrenceId, provenanceKind, fence);
   }
 }
 

+ 435 - 14
src/hooks/task-session-manager/index.test.ts

@@ -2925,7 +2925,7 @@ describe('task-session-manager hook', () => {
     });
   });
 
-  test('new synthetic message occurrence updates board after task relaunch with same state/result', async () => {
+  test('does not accept an unobserved weak occurrence after task relaunch', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
 
@@ -2967,9 +2967,9 @@ describe('task-session-manager hook', () => {
 
     await transformMessages(hook, firstMessages);
     expect(board.get('child-1')).toMatchObject({
-      state: 'completed',
-      terminalUnreconciled: true,
-      resultSummary: 'same result',
+      state: 'running',
+      statusUncertain: true,
+      terminalUnreconciled: false,
     });
 
     // Relaunch same task ID
@@ -2985,7 +2985,7 @@ describe('task-session-manager hook', () => {
       terminalUnreconciled: false,
     });
 
-    // New synthetic message occurrence with same state/result - should update to terminal
+    // A weak occurrence without runtime provenance remains fail-closed.
     const secondMessages = {
       messages: [
         {
@@ -3016,11 +3016,11 @@ describe('task-session-manager hook', () => {
 
     await transformMessages(hook, secondMessages);
 
-    // Should be terminal again because this is a new message occurrence
+    // The unobserved weak occurrence remains fail-closed.
     expect(board.get('child-1')).toMatchObject({
-      state: 'completed',
-      terminalUnreconciled: true,
-      resultSummary: 'same result',
+      state: 'running',
+      statusUncertain: true,
+      terminalUnreconciled: false,
     });
   });
 
@@ -5467,8 +5467,8 @@ describe('task-session-manager hook', () => {
 
     const oldCompletion = {
       type: 'text',
-      id: 'generation-one-completion',
       synthetic: true,
+      messageID: 'generation-one-message',
       text: [
         '<task id="child-relaunch" state="completed">',
         '<summary>Background task completed: first run</summary>',
@@ -5757,7 +5757,7 @@ describe('task-session-manager hook', () => {
     expect(terminalListener).not.toHaveBeenCalled();
   });
 
-  test('does not upgrade an ambiguous occurrence without a deletion epoch', async () => {
+  test('accepts a host messageID occurrence in the current generation', async () => {
     const board = new BackgroundJobBoard();
     const terminalListener = mock(() => {});
     board.addTerminalStateListener(terminalListener);
@@ -5772,7 +5772,7 @@ describe('task-session-manager hook', () => {
       description: 'ambiguous observation',
     });
 
-    const completion = {
+    const observedCompletion = {
       type: 'text',
       synthetic: true,
       messageID: 'ambiguous-message',
@@ -5788,23 +5788,444 @@ describe('task-session-manager hook', () => {
     await hook.event({
       event: {
         type: 'message.part.updated',
-        properties: { part: completion },
+        properties: { part: observedCompletion },
       },
     });
+    const transformedCompletion = { ...observedCompletion };
+    delete transformedCompletion.messageID;
     await hook['experimental.chat.messages.transform']({}, {
       messages: [
         {
           info: {
+            id: 'ambiguous-message',
             role: 'user',
             agent: 'orchestrator',
             sessionID: 'parent-1',
           },
-          parts: [completion],
+          parts: [transformedCompletion],
+        },
+      ],
+    } as never);
+
+    await hook['experimental.chat.messages.transform']({}, {
+      messages: [
+        {
+          info: {
+            id: 'ambiguous-message',
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ ...transformedCompletion }],
         },
       ],
     } as never);
 
     expect(board.get('child-ambiguous')).toMatchObject({
+      state: 'completed',
+      resultSummary: 'uncertain result',
+      terminalUnreconciled: true,
+    });
+    expect(terminalListener).toHaveBeenCalledTimes(1);
+  });
+
+  test('keeps a host completion without observed provenance fail-closed', async () => {
+    const board = new BackgroundJobBoard();
+    const terminalListener = mock(() => {});
+    board.addTerminalStateListener(terminalListener);
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      runtimeStatusReconcileDelayMs: 60_000,
+    });
+    board.registerLaunch({
+      taskID: 'child-no-host-provenance',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'no host provenance',
+    });
+
+    const completion = {
+      type: 'text',
+      synthetic: true,
+      messageID: 'unobserved-host-message',
+      text: [
+        '<task id="child-no-host-provenance" state="completed">',
+        '<summary>Background task completed: unobserved</summary>',
+        '<task_result>unobserved result</task_result>',
+        '</task>',
+      ].join('\n'),
+    };
+    await hook['experimental.chat.messages.transform']({}, {
+      messages: [
+        {
+          info: {
+            id: 'unobserved-host-message',
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ ...completion, messageID: undefined }],
+        },
+      ],
+    } as never);
+
+    expect(board.get('child-no-host-provenance')).toMatchObject({
+      state: 'running',
+      statusUncertain: true,
+      terminalUnreconciled: false,
+    });
+    expect(terminalListener).not.toHaveBeenCalled();
+  });
+
+  test('keeps an exact explicit ID collision independent from host provenance', async () => {
+    const board = new BackgroundJobBoard();
+    const terminalListener = mock(() => {});
+    board.addTerminalStateListener(terminalListener);
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      runtimeStatusReconcileDelayMs: 60_000,
+    });
+    board.registerLaunch({
+      taskID: 'child-explicit-host-prefix',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'explicit host prefix',
+    });
+    const hostCompletion = {
+      type: 'text',
+      synthetic: true,
+      messageID: 'collision-host-message',
+      text: [
+        '<task id="child-explicit-host-prefix" state="completed">',
+        '<summary>Background task completed: host</summary>',
+        '<task_result>host result</task_result>',
+        '</task>',
+      ].join('\n'),
+    };
+    await hook.event({
+      event: {
+        type: 'message.part.updated',
+        properties: { part: hostCompletion },
+      },
+    });
+    const hostOrigin = [
+      ...getBackgroundJobLifecycleLedger(board).syntheticTerminalOccurrences.values(),
+    ][0];
+    if (!hostOrigin) throw new Error('host origin was not recorded');
+
+    const explicitCompletion = {
+      type: 'text',
+      id: hostOrigin.occurrenceID,
+      synthetic: true,
+      text: [
+        '<task id="child-explicit-host-prefix" state="completed">',
+        '<summary>Background task completed: explicit</summary>',
+        '<task_result>explicit result</task_result>',
+        '</task>',
+      ].join('\n'),
+    };
+    await hook.event({
+      event: {
+        type: 'message.part.updated',
+        properties: { part: explicitCompletion },
+      },
+    });
+    await hook['experimental.chat.messages.transform']({}, {
+      messages: [
+        {
+          info: {
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [hostCompletion],
+        },
+      ],
+    } as never);
+    await hook['experimental.chat.messages.transform']({}, {
+      messages: [
+        {
+          info: {
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [explicitCompletion],
+        },
+      ],
+    } as never);
+
+    expect(board.get('child-explicit-host-prefix')).toMatchObject({
+      state: 'completed',
+      resultSummary: 'explicit result',
+      terminalUnreconciled: true,
+    });
+    expect(terminalListener).toHaveBeenCalledTimes(1);
+  });
+
+  test('rejects weak provenance after relaunch without session deletion', async () => {
+    const board = new BackgroundJobBoard();
+    const terminalListener = mock(() => {});
+    board.addTerminalStateListener(terminalListener);
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      runtimeStatusReconcileDelayMs: 60_000,
+    });
+    board.registerLaunch({
+      taskID: 'child-undetected-relaunch',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'first run',
+    });
+    const completion = {
+      type: 'text',
+      synthetic: true,
+      messageID: 'undetected-relaunch-message',
+      text: [
+        '<task id="child-undetected-relaunch" state="completed">',
+        '<summary>Background task completed: first run</summary>',
+        '<task_result>old result</task_result>',
+        '</task>',
+      ].join('\n'),
+    };
+    board.updateStatus({
+      taskID: 'child-undetected-relaunch',
+      state: 'completed',
+      resultSummary: 'first run',
+    });
+    const relaunched = board.registerLaunch({
+      taskID: 'child-undetected-relaunch',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'second run',
+    });
+    terminalListener.mockClear();
+    // The old message is first observed only after the same-ID relaunch.
+    await hook.event({
+      event: {
+        type: 'message.part.updated',
+        properties: { part: completion },
+      },
+    });
+
+    await hook['experimental.chat.messages.transform']({}, {
+      messages: [
+        {
+          info: {
+            id: 'undetected-relaunch-message',
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ ...completion, messageID: undefined }],
+        },
+      ],
+    } as never);
+
+    expect(board.get('child-undetected-relaunch')).toMatchObject({
+      generation: relaunched.generation,
+      state: 'running',
+      statusUncertain: true,
+      resultSummary: undefined,
+    });
+    expect(terminalListener).not.toHaveBeenCalled();
+  });
+
+  test('does not reject weak provenance after an unrelated task deletion', async () => {
+    const board = new BackgroundJobBoard();
+    const terminalListener = mock(() => {});
+    board.addTerminalStateListener(terminalListener);
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      runtimeStatusReconcileDelayMs: 60_000,
+    });
+    board.registerLaunch({
+      taskID: 'child-unrelated-delete',
+      parentSessionID: 'other-parent',
+      agent: 'explorer',
+      description: 'child-unrelated-delete',
+    });
+    board.registerLaunch({
+      taskID: 'child-valid-host',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'child-valid-host',
+    });
+    const completion = {
+      type: 'text',
+      synthetic: true,
+      messageID: 'valid-host-message',
+      text: [
+        '<task id="child-valid-host" state="completed">',
+        '<summary>Background task completed: valid host</summary>',
+        '<task_result>valid result</task_result>',
+        '</task>',
+      ].join('\n'),
+    };
+    await hook.event({
+      event: {
+        type: 'message.part.updated',
+        properties: { part: completion },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'session.deleted',
+        properties: { sessionID: 'child-unrelated-delete' },
+      },
+    });
+
+    await hook['experimental.chat.messages.transform']({}, {
+      messages: [
+        {
+          info: {
+            id: 'valid-host-message',
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ ...completion, messageID: undefined }],
+        },
+      ],
+    } as never);
+
+    expect(board.get('child-valid-host')).toMatchObject({
+      state: 'completed',
+      resultSummary: 'valid result',
+      terminalUnreconciled: true,
+    });
+    expect(terminalListener).toHaveBeenCalledTimes(1);
+  });
+
+  test('keeps a distinct weak origin after processing the first one fail-closed', async () => {
+    const board = new BackgroundJobBoard();
+    const terminalListener = mock(() => {});
+    board.addTerminalStateListener(terminalListener);
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      runtimeStatusReconcileDelayMs: 60_000,
+    });
+    board.registerLaunch({
+      taskID: 'child-processed-weak-origin',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'processed weak origin',
+    });
+    const completion = (result: string) => ({
+      type: 'text',
+      synthetic: true,
+      messageID: 'shared-processed-message',
+      text: [
+        '<task id="child-processed-weak-origin" state="completed">',
+        '<summary>Background task completed: processed</summary>',
+        `<task_result>${result}</task_result>`,
+        '</task>',
+      ].join('\n'),
+    });
+    const first = completion('first result');
+    await hook.event({
+      event: {
+        type: 'message.part.updated',
+        properties: { part: first },
+      },
+    });
+    await hook['experimental.chat.messages.transform']({}, {
+      messages: [
+        {
+          info: {
+            id: 'shared-processed-message',
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ ...first, messageID: undefined }],
+        },
+      ],
+    } as never);
+
+    const second = completion('second result');
+    await hook.event({
+      event: {
+        type: 'message.part.updated',
+        properties: { part: second },
+      },
+    });
+    await hook['experimental.chat.messages.transform']({}, {
+      messages: [
+        {
+          info: {
+            id: 'shared-processed-message',
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [{ ...second, messageID: undefined }],
+        },
+      ],
+    } as never);
+
+    expect(board.get('child-processed-weak-origin')).toMatchObject({
+      state: 'completed',
+      resultSummary: 'first result',
+      terminalUnreconciled: true,
+    });
+    expect(terminalListener).toHaveBeenCalledTimes(1);
+  });
+
+  test('keeps multiple current-generation host messageID origins fail-closed', async () => {
+    const board = new BackgroundJobBoard();
+    const terminalListener = mock(() => {});
+    board.addTerminalStateListener(terminalListener);
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      runtimeStatusReconcileDelayMs: 60_000,
+    });
+    board.registerLaunch({
+      taskID: 'child-multiple-host-origins',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'multiple host origins',
+    });
+
+    const completion = (messageID: string, result: string) => ({
+      type: 'text',
+      synthetic: true,
+      messageID,
+      text: [
+        '<task id="child-multiple-host-origins" state="completed">',
+        '<summary>Background task completed: multiple origins</summary>',
+        '<task_result>',
+        result,
+        '</task_result>',
+        '</task>',
+      ].join('\n'),
+    });
+    const first = completion('host-message-shared', 'first result');
+    const second = completion('host-message-shared', 'second result');
+
+    for (const part of [first, second]) {
+      await hook.event({
+        event: {
+          type: 'message.part.updated',
+          properties: { part },
+        },
+      });
+    }
+
+    await hook['experimental.chat.messages.transform']({}, {
+      messages: [
+        {
+          info: {
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [first],
+        },
+      ],
+    } as never);
+
+    expect(board.get('child-multiple-host-origins')).toMatchObject({
       state: 'running',
       statusUncertain: true,
       terminalUnreconciled: false,

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

@@ -64,6 +64,8 @@ export interface BackgroundJobRecord {
   lastLaunchedAt: number;
   /** Monotonic run identity. Explicit relaunch/reuse increments it. */
   generation: number;
+  /** Task-local run identity; unlike generation, unrelated tasks do not affect it. */
+  taskGeneration: number;
   /** First launch observation for the current generation. */
   runStartedAt: number;
   /** Persistent hard wall-clock marker; distinct from external task wait timeout. */
@@ -263,6 +265,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       const updated = {
         ...existing,
         generation,
+        taskGeneration: existing.taskGeneration + 1,
         agent: input.agent || existing.agent,
         description: input.description || existing.description,
         objective: input.objective ?? existing.objective,
@@ -294,6 +297,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     const record: BackgroundJobRecord = {
       taskID: input.taskID,
       generation,
+      taskGeneration: 1,
       parentSessionID: input.parentSessionID,
       agent: input.agent,
       description: input.description || `background ${input.agent} task`,