Browse Source

fix: disambiguate anonymous board anchors

Alvin Unreal 1 week ago
parent
commit
4df32b15b0

+ 42 - 0
src/hooks/task-session-manager/board-cache-breakpoint.test.ts

@@ -58,6 +58,13 @@ function userMsg(id: string, text: string) {
   };
 }
 
+function anonymousUserMsg(text: string) {
+  return {
+    info: { role: 'user', agent: 'orchestrator', sessionID: SESSION },
+    parts: [{ type: 'text', text }],
+  };
+}
+
 /** An assistant turn issuing a tool call, followed by its user tool_result. */
 function toolTurn(id: string, output: string) {
   return [
@@ -387,6 +394,41 @@ describe('background job board cache breakpoint stability', () => {
     ).toBe(true);
   });
 
+  test('duplicate anonymous user turns preserve the first board on append', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'research',
+    });
+    const hook = createHook(board);
+
+    const firstRequest = await inject(hook, [anonymousUserMsg('continue')]);
+    const firstMessage = firstRequest[0] as Msg;
+    expect(
+      firstMessage.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
+    ).toBe(true);
+
+    const secondRequest = await inject(hook, [
+      anonymousUserMsg('continue'),
+      anonymousUserMsg('continue'),
+    ]);
+
+    // The first anonymous message is the same append-stable anchor, while the
+    // second occurrence receives the fresh tail board.
+    expect(JSON.stringify(secondRequest[0])).toBe(
+      JSON.stringify(firstRequest[0]),
+    );
+    expect(
+      (secondRequest as Msg[]).flatMap((message) =>
+        message.parts.filter(
+          (part) => part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
+        ),
+      ),
+    ).toHaveLength(2);
+  });
+
   test('field-dump scenario: tail is a tool_result user turn preceded by an assistant turn', async () => {
     // Reconstructs the real bust (2026-07-23 dumps 000166→000167): the tail was
     // a user tool_result message preceded by an assistant tool-call message,

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

@@ -84,6 +84,11 @@ type RetainedTailBoard = {
   text: string;
 };
 
+type BoardAnchor = {
+  message: MessageWithParts;
+  id: string;
+};
+
 // ── State shape ────────────────────────────────────────────────────────
 
 export type InjectedTerminalJobs = {
@@ -545,10 +550,10 @@ function injectLatestBoard(state: InjectionState, messages: unknown[]): void {
   // freshening its board is free. Every earlier message was already sent, so
   // its board must never change.
   const anchor = findBoardAnchor(messages, state.metadataKey);
-  const anchorId = anchor ? boardAnchorId(anchor) : undefined;
+  const anchorId = anchor?.id;
 
   // Strip the board from the current tail zone only (byte-safe volatile zone).
-  stripCurrentTailBoard(messages, state.metadataKey, anchor);
+  stripCurrentTailBoard(messages, state.metadataKey, anchor?.message);
 
   // Eligibility is driven by the most recent orchestrator user message (the
   // triggering turn), which also guards against specialist/internal turns.
@@ -627,9 +632,9 @@ function injectLatestBoard(state: InjectionState, messages: unknown[]): void {
   //   (different role), so the assistant message keeps its own readable
   //   breakpoint, and it uses the USER `trigger.info` — never `anchor.info` —
   //   so the message carrying board text is genuinely user-role (A3).
-  const recordId = anchorId ?? boardAnchorFallbackId(anchor);
-  if (canCarryBoardPart(anchor)) {
-    appendTaggedSyntheticPart(anchor, {
+  const recordId = anchor.id;
+  if (canCarryBoardPart(anchor.message)) {
+    appendTaggedSyntheticPart(anchor.message, {
       text: reminder,
       metadataKey: state.metadataKey,
     });
@@ -669,19 +674,8 @@ function injectLatestBoard(state: InjectionState, messages: unknown[]): void {
 function findBoardAnchor(
   messages: unknown[],
   metadataKey: string,
-): MessageWithParts | undefined {
-  for (let i = messages.length - 1; i >= 0; i -= 1) {
-    const message = messages[i];
-    if (!isMessageWithParts(message)) continue;
-    if (
-      message.parts.length > 0 &&
-      message.parts.every((part) => isTaggedPart(part, metadataKey))
-    ) {
-      continue;
-    }
-    return message;
-  }
-  return undefined;
+): BoardAnchor | undefined {
+  return boardAnchors(messages, metadataKey).at(-1);
 }
 
 /**
@@ -716,26 +710,68 @@ function stripCurrentTailBoard(
 /**
  * A stable id for an anchor message that lacks an `info.id` (test fixtures,
  * legacy shapes). Derived from role + concatenated REAL text (tagged board
- * parts excluded) so the id is identical whether or not a board currently
- * rides on the message — otherwise appending a board would change the id and
- * defeat cross-request anchor matching.
+ * parts excluded) plus its append-order occurrence, so duplicate anonymous
+ * messages remain distinct while appending a later message leaves existing
+ * ids unchanged. The occurrence is internal and never enters the payload.
  */
-function boardAnchorFallbackId(message: MessageWithParts): string {
+function boardAnchorFallbackId(
+  message: MessageWithParts,
+  occurrence: number,
+  metadataKey: string,
+): string {
+  return `anon:${djb2Hash(boardAnchorFallbackBase(message, metadataKey))}:${occurrence}`;
+}
+
+function boardAnchorFallbackBase(
+  message: MessageWithParts,
+  metadataKey: string,
+): string {
   const text = message.parts
     .filter(
       (part) =>
-        !isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY) &&
+        !isTaggedPart(part, metadataKey) &&
         part.type === 'text' &&
         typeof part.text === 'string',
     )
     .map((part) => part.text)
     .join('\u0000');
-  return `anon:${djb2Hash(`${message.info.role}:${text}`)}`;
+  return `${message.info.role}:${text}`;
 }
 
-/** The id used to key a message in the retained-tail-board log. */
-function boardAnchorId(message: MessageWithParts): string {
-  return message.info.id ?? boardAnchorFallbackId(message);
+/**
+ * Build internal anchor identities in append order. Anonymous messages use an
+ * occurrence suffix so duplicate content remains distinct, while appending a
+ * later message leaves all existing identities unchanged. The identity never
+ * enters the provider-visible message payload.
+ */
+function boardAnchors(messages: unknown[], metadataKey: string): BoardAnchor[] {
+  const occurrences = new Map<string, number>();
+  const anchors: BoardAnchor[] = [];
+
+  for (const candidate of messages) {
+    if (!isMessageWithParts(candidate)) continue;
+    if (
+      candidate.parts.length > 0 &&
+      candidate.parts.every((part) => isTaggedPart(part, metadataKey))
+    ) {
+      continue;
+    }
+
+    if (candidate.info.id !== undefined) {
+      anchors.push({ message: candidate, id: candidate.info.id });
+      continue;
+    }
+
+    const base = boardAnchorFallbackBase(candidate, metadataKey);
+    const occurrence = occurrences.get(base) ?? 0;
+    occurrences.set(base, occurrence + 1);
+    anchors.push({
+      message: candidate,
+      id: boardAnchorFallbackId(candidate, occurrence, metadataKey),
+    });
+  }
+
+  return anchors;
 }
 
 /** Record (or refresh) a board placed on an anchor for later replay. */
@@ -798,15 +834,8 @@ function replayRetainedTailBoards(
   if (!perSession || perSession.size === 0) return;
 
   const anchorById = new Map<string, MessageWithParts>();
-  for (const message of messages) {
-    if (!isMessageWithParts(message)) continue;
-    if (
-      message.parts.length > 0 &&
-      message.parts.every((part) => isTaggedPart(part, state.metadataKey))
-    ) {
-      continue;
-    }
-    anchorById.set(boardAnchorId(message), message);
+  for (const anchor of boardAnchors(messages, state.metadataKey)) {
+    anchorById.set(anchor.id, anchor.message);
   }
 
   for (const [anchorId, board] of [...perSession.entries()]) {