Browse Source

fix: replay already-sent tail board instead of stripping it on tail advance

The latest-strategy board injection stripped the previous tail's board and
re-appended a fresh board on the new tail (stripTailBoardContent + append).
Because the board is never persisted, opencode rebuilds real history
board-free every request, so the old tail (already SENT to the provider WITH
its board) came back board-free -- rewriting an already-cached message and
invalidating the Anthropic prompt-cache prefix from that message onward. The
whole tail re-cached every turn (field bust: ses_11145863 dumps
000086->000087, old-tail user message 1376B->652B as its board vanished; live
cache-read frozen at 188,618 while cache-write grew 158k->171k per call).

Fix (append-only w.r.t. already-sent messages): keep the board volatile ONLY
on the current tail zone (strip + fresh re-append there, byte-safe because the
tail re-caches anyway), and replay every board placed on a message that is no
longer the tail byte-identically on its original anchor via a per-session
retained-board log keyed by anchor id. A board on any earlier message is never
mutated or stripped. Preserves the #889 tail-anchoring placement (trailing
part on a user tail; separate trailing message on an assistant tail), so the
tail breakpoint still lands on stable content and the same-role coalescing
regression is not reintroduced. Removes the now-unused stripTailBoardContent.

Adds a board-cache-breakpoint.test.ts case reconstructing dumps 000086->000087:
build request A with the board on the tail user message, append two new
messages, run the transform for request B, and assert every message present in
A is byte-identical in B except the genuinely new tail messages -- in
particular the old-tail message keeps its board. Fails pre-fix, passes after.
Tsanko Tsanev 2 weeks ago
parent
commit
208b6560a6

+ 0 - 39
src/hooks/cache-safe-injection.ts

@@ -106,45 +106,6 @@ export function stripTaggedContent(
   }
 }
 
-/**
- * Remove tagged parts only from the trailing zone of the payload — the
- * contiguous run of whole synthetic tagged messages at the end plus the
- * single real message immediately before them (where per-request volatile
- * content is appended as a trailing part).
- *
- * A tagged part found genuinely mid-history (e.g. a legacy or accidentally
- * persisted board) is LEFT in place: removing it would rewrite the bytes of
- * an already-sent message and invalidate the provider cache for the entire
- * tail after it. Leaving it costs nothing (it is already part of the cached
- * prefix), while removing it costs a full tail re-cache.
- */
-export function stripTailBoardContent(
-  messages: unknown[],
-  metadataKey: string,
-): void {
-  let i = messages.length - 1;
-
-  // Drop whole trailing synthetic tagged messages.
-  while (i >= 0) {
-    const message = messages[i];
-    if (!isVolatileTaggedMessage(message, metadataKey)) break;
-    messages.splice(i, 1);
-    i -= 1;
-  }
-
-  // Strip a tagged trailing part from the now-last real message only.
-  if (i >= 0) {
-    const message = messages[i];
-    if (isMessageWithParts(message)) {
-      const hadParts = message.parts.length > 0;
-      message.parts = message.parts.filter(
-        (part) => !isTaggedPart(part, metadataKey),
-      );
-      if (hadParts && message.parts.length === 0) messages.splice(i, 1);
-    }
-  }
-}
-
 /**
  * Append volatile content as its own synthetic message at the very end of
  * the payload. Call `stripTaggedContent` first so at most one instance

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

@@ -326,6 +326,67 @@ describe('background job board cache breakpoint stability', () => {
     expect(outN1.slice(0, outN.length)).toEqual(outN);
   });
 
+  test('an already-sent tail board is not stripped when the tail advances (dumps 000086->000087)', async () => {
+    // Faithful reconstruction of the live cache bust (ses_11145863, dumps
+    // 000086 A -> 000087 B). In A the tail was a user tool_result message that
+    // carried the board as an appended trailing part; that request was SENT to
+    // the provider and cached with the board on that message. B then advanced
+    // by two new messages (assistant + user tool_result). The provider caches a
+    // byte prefix, so every message it already received in A must be byte-
+    // identical in B — INCLUDING the board bytes on the old tail. The #889
+    // append-on-tail placement dropped that board when the tail advanced,
+    // rewriting the already-sent old-tail message (A: 1376B -> B: 652B in the
+    // field dump) and busting the cache prefix from that message onward.
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_child',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'grok research',
+    });
+    const hook = createHook(board);
+
+    // FULL serialization including the board part — a byte-exact fingerprint of
+    // what the provider actually received for each message.
+    const fullSerialize = (messages: unknown[]): string[] =>
+      (messages as Msg[]).map((m) => JSON.stringify(m));
+
+    // Request A: tail is a user tool_result turn; board rides on it as a
+    // trailing part (the #889 "tail is user" branch, matching dump 000086).
+    const historyA = [userMsg('u1', 'Coordinate'), ...toolTurn('t1', 'r1')];
+    const outA = await inject(hook, historyA);
+    const serA = fullSerialize(outA);
+
+    // The old tail carried the board (as sent to the provider in request A).
+    const oldTailA = outA.at(-1) as Msg;
+    expect(oldTailA.info.role).toBe('user');
+    expect(
+      oldTailA.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
+    ).toBe(true);
+
+    // Request B: the loop advanced by exactly two new messages (assistant +
+    // user tool_result), matching dump 000087's two extra tail messages.
+    const historyB = [
+      userMsg('u1', 'Coordinate'),
+      ...toolTurn('t1', 'r1'),
+      ...toolTurn('t2', 'r2'),
+    ];
+    const outB = await inject(hook, historyB);
+    const serB = fullSerialize(outB);
+
+    // Every message the provider received in request A must be byte-identical
+    // in request B, board bytes included. In particular the old tail (index
+    // serA.length - 1) must still carry its board — it must NOT be stripped.
+    expect(serB.slice(0, serA.length)).toEqual(serA);
+
+    // Explicit guard on the exact failure the field dump showed: the old-tail
+    // message keeps its board trailing part in B.
+    const oldTailB = outB[serA.length - 1] as Msg;
+    expect(
+      oldTailB.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
+    ).toBe(true);
+  });
+
   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,

+ 252 - 40
src/hooks/task-session-manager/board-injection.ts

@@ -23,9 +23,10 @@ import {
   appendTaggedSyntheticPart,
   appendTrailingVolatileMessage,
   createTaggedSyntheticPart,
+  hasTaggedPart,
   isTaggedPart,
+  isVolatileTaggedMessage,
   stripTaggedContent,
-  stripTailBoardContent,
 } from '../cache-safe-injection';
 import type { MessagePart, MessageWithParts } from '../types';
 import { isMessageWithParts, isUserMessageWithParts } from '../types';
@@ -59,6 +60,21 @@ export type RetainedBoardSnapshotState = {
   firstRealMessageAnchorKey?: string;
 };
 
+/**
+ * A board the `latest` strategy has already placed (and therefore already sent
+ * to the provider) on a specific anchor message. Replayed byte-identically on
+ * every later request once that anchor is no longer the tail, so a board that
+ * was sent on a message the provider has cached never disappears. `anchorRole`
+ * records how it was placed: a `user` anchor carried the board as a trailing
+ * PART; an `assistant` anchor was followed by a separate synthetic board
+ * message.
+ */
+type RetainedTailBoard = {
+  anchorId: string;
+  anchorRole: string;
+  text: string;
+};
+
 // ── State shape ────────────────────────────────────────────────────────
 
 export interface InjectionState {
@@ -77,6 +93,15 @@ export interface InjectionState {
     prune(board: { taskIDs(): Set<string> }): void;
   };
   retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
+  /**
+   * Per-session log of boards the `latest` strategy has placed on real anchor
+   * messages, keyed by anchor message id. Once an anchor is no longer the tail
+   * its board is replayed byte-identically every later request, so a board sent
+   * on a now-mid-history message is never stripped (which would rewrite already
+   * cached bytes and bust the provider prompt-cache prefix from that message
+   * onward - the field bust in dumps 000086->000087).
+   */
+  retainedTailBoards: Map<string, Map<string, RetainedTailBoard>>;
 }
 
 // ── Helpers ────────────────────────────────────────────────────────────
@@ -317,53 +342,72 @@ export async function injectBackgroundJobBoard(
 ): Promise<void> {
   const messages = Array.isArray(output.messages) ? output.messages : [];
 
-  if (state.strategy === 'latest') {
-    // Strip only the previous request's TAIL board (a trailing tagged part on
-    // the last real message, plus any whole synthetic trailing board
-    // messages). A tagged board found genuinely mid-history is left untouched:
-    // removing it would rewrite already-sent bytes and bust the whole tail.
-    stripTailBoardContent(messages, state.metadataKey);
-  }
-
   if (state.strategy === 'checkpoint-compatible') {
     injectCheckpointBoard(state, messages);
     return;
   }
 
-  // Find the anchor: the last real (non-fully-tagged) message. It decides
-  // orchestrator/session eligibility and is where the board attaches so it
-  // stays strictly at the tail.
-  let anchor: 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, state.metadataKey))
-    ) {
-      continue;
-    }
-    anchor = message;
-    break;
-  }
-  if (!anchor) return;
+  injectLatestBoard(state, messages);
+}
+
+/**
+ * `latest` strategy: keep exactly one FRESH board on the current tail while
+ * every board already sent on an earlier (now mid-history) message stays put,
+ * byte-identical.
+ *
+ * The board is never persisted, so opencode rebuilds real history board-free
+ * each request. That means the plugin — not storage — must reproduce every
+ * board it previously placed. The prior implementation instead STRIPPED the
+ * old tail's board and re-appended a fresh board on the new tail
+ * (`stripTailBoardContent` + append). Because the old tail was already sent to
+ * the provider WITH its board, dropping it rewrote an already-cached message
+ * and invalidated the provider prompt-cache prefix from that message onward —
+ * the whole tail re-cached every turn (field bust: ses_11145863 dumps
+ * 000086→000087, old-tail user message 1376B→652B as its board vanished).
+ *
+ * Fix (append-only w.r.t. already-sent messages):
+ *   1. Strip the board ONLY from the current tail zone (the tail message's
+ *      trailing board part + whole synthetic board messages trailing it). That
+ *      zone re-caches every turn, so rewriting it is byte-safe.
+ *   2. Replay every FROZEN board (one placed on a message that is no longer the
+ *      tail) byte-identically on its original anchor, so an already-sent board
+ *      never disappears.
+ *   3. Add ONE fresh board to the current tail, preserving the #889 placement
+ *      (trailing PART on a user tail; separate trailing message on an assistant
+ *      tail) so the tail breakpoint still lands on stable content, and record
+ *      it so the NEXT request can freeze/replay it once the tail advances.
+ * A board on any earlier message is never mutated or stripped.
+ */
+function injectLatestBoard(state: InjectionState, messages: unknown[]): void {
+  // The current tail anchor: the last real (non-fully-tagged) message. It is
+  // the ONLY message whose board is volatile — the tail re-caches anyway, so
+  // 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;
+
+  // Strip the board from the current tail zone only (byte-safe volatile zone).
+  stripCurrentTailBoard(messages, state.metadataKey, anchor);
 
   // Eligibility is driven by the most recent orchestrator user message (the
-  // triggering turn), which also guards against injecting on specialist
-  // sessions or internal-initiator turns.
+  // triggering turn), which also guards against specialist/internal turns.
   const trigger = findTriggeringUserMessage(messages, state.metadataKey);
+  const sessionID = trigger?.info.sessionID;
+
+  // Replay frozen boards even when this turn is ineligible for a fresh board
+  // (internal-initiator turn, empty board): an already-sent board must stay put
+  // regardless of the current turn. The current tail anchor is skipped — its
+  // board is (re)placed fresh below.
+  if (sessionID !== undefined) {
+    replayRetainedTailBoards(state, sessionID, messages, anchorId);
+  }
+
   if (!trigger) return;
   if (trigger.info.agent && trigger.info.agent !== 'orchestrator') return;
-  if (
-    !trigger.info.sessionID ||
-    !state.shouldManageSession(trigger.info.sessionID)
-  ) {
-    return;
-  }
+  if (!sessionID || !state.shouldManageSession(sessionID)) return;
+  if (!anchor) return;
 
-  const reminder = state.backgroundJobBoard.formatForPrompt(
-    trigger.info.sessionID,
-  );
+  const reminder = state.backgroundJobBoard.formatForPrompt(sessionID);
   if (!reminder) return;
 
   const textPart = trigger.parts.find(
@@ -371,7 +415,7 @@ export async function injectBackgroundJobBoard(
   );
   if (!textPart || isInternalInitiatorPart(textPart)) return;
 
-  rememberInjectedTerminalJobs(state, trigger.info.sessionID);
+  rememberInjectedTerminalJobs(state, sessionID);
 
   // Placement rules (prompt-cache safety):
   //
@@ -391,13 +435,20 @@ export async function injectBackgroundJobBoard(
   //   message does NOT merge (different role), so the assistant message keeps
   //   its own readable breakpoint.
   //
-  // Either way the board never invalidates already-sent bytes: it is never
-  // persisted, so the next request rebuilds real history board-free.
+  // Recording the placement under the tail's anchor id lets the NEXT request
+  // (once the tail advances) replay this exact board on this exact message, so
+  // the bytes the provider just cached for this message never change.
+  const recordId = anchorId ?? boardAnchorFallbackId(anchor);
   if (anchor.info.role === 'user') {
     appendTaggedSyntheticPart(anchor, {
       text: reminder,
       metadataKey: state.metadataKey,
     });
+    rememberTailBoard(state, sessionID, {
+      anchorId: recordId,
+      anchorRole: 'user',
+      text: reminder,
+    });
   } else {
     appendTrailingVolatileMessage(
       messages,
@@ -410,6 +461,167 @@ export async function injectBackgroundJobBoard(
         metadataKey: state.metadataKey,
       },
     );
+    rememberTailBoard(state, sessionID, {
+      anchorId: recordId,
+      anchorRole: 'assistant',
+      text: reminder,
+    });
+  }
+}
+
+/** The last real (non-fully-tagged) message — the current tail anchor. */
+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;
+}
+
+/**
+ * Strip the board ONLY from the current tail zone: whole synthetic board
+ * messages trailing the payload, plus a trailing board part on the current
+ * tail anchor. This is the volatile zone (the tail re-caches every turn), so
+ * rewriting it is byte-safe. A board on any earlier message is untouched —
+ * removing it would rewrite already-sent, already-cached bytes.
+ */
+function stripCurrentTailBoard(
+  messages: unknown[],
+  metadataKey: string,
+  anchor: MessageWithParts | undefined,
+): void {
+  // Drop whole synthetic board messages trailing the payload.
+  let i = messages.length - 1;
+  while (i >= 0) {
+    const message = messages[i];
+    if (!isVolatileTaggedMessage(message, metadataKey)) break;
+    messages.splice(i, 1);
+    i -= 1;
+  }
+
+  // Strip a trailing board part from the current tail anchor only.
+  if (anchor) {
+    anchor.parts = anchor.parts.filter(
+      (part) => !isTaggedPart(part, metadataKey),
+    );
+  }
+}
+
+/**
+ * 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.
+ */
+function boardAnchorFallbackId(message: MessageWithParts): string {
+  const text = message.parts
+    .filter(
+      (part) =>
+        !isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY) &&
+        part.type === 'text' &&
+        typeof part.text === 'string',
+    )
+    .map((part) => part.text)
+    .join('\u0000');
+  return `anon:${djb2Hash(`${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);
+}
+
+/** Record (or refresh) a board placed on an anchor for later replay. */
+function rememberTailBoard(
+  state: InjectionState,
+  sessionID: string,
+  board: RetainedTailBoard,
+): void {
+  const perSession =
+    state.retainedTailBoards.get(sessionID) ??
+    new Map<string, RetainedTailBoard>();
+  perSession.set(board.anchorId, board);
+  state.retainedTailBoards.set(sessionID, perSession);
+}
+
+/**
+ * Re-append every FROZEN retained board onto its original anchor message,
+ * exactly as first sent, so a board that was sent on a message which is no
+ * longer the tail never disappears (its bytes are already in the provider's
+ * cached prefix).
+ *
+ * The current tail anchor (`currentAnchorId`) is skipped: its board is volatile
+ * and is (re)placed fresh by the caller. Anchors no longer present in history
+ * (compaction, revert) are pruned — their bytes are gone from the provider's
+ * view too. Replay is skipped when the anchor already carries a board, keeping
+ * the operation idempotent under repeated transforms on a shared array.
+ */
+function replayRetainedTailBoards(
+  state: InjectionState,
+  sessionID: string,
+  messages: unknown[],
+  currentAnchorId: string | undefined,
+): void {
+  const perSession = state.retainedTailBoards.get(sessionID);
+  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 [anchorId, board] of [...perSession.entries()]) {
+    // The current tail's board is volatile — the caller strips and re-appends
+    // it. Never freeze/replay it here.
+    if (anchorId === currentAnchorId) continue;
+
+    const anchor = anchorById.get(anchorId);
+    if (!anchor) {
+      // Anchor gone from history (compaction/revert): its bytes are no longer
+      // in the provider's view, so stop tracking it.
+      perSession.delete(anchorId);
+      continue;
+    }
+    if (hasTaggedPart(anchor, state.metadataKey)) continue;
+    if (board.anchorRole === 'user') {
+      appendTaggedSyntheticPart(anchor, {
+        text: board.text,
+        metadataKey: state.metadataKey,
+      });
+    } else {
+      // Assistant anchor: the board was a separate synthetic message that
+      // immediately followed the anchor. Reinsert it right after the anchor so
+      // its position (and therefore the cached byte offset) is reproduced.
+      const index = messages.indexOf(anchor);
+      const boardMessage: MessageWithParts = {
+        info: { ...anchor.info, id: `${anchorId}-background-job-board` },
+        parts: [
+          createTaggedSyntheticPart({
+            text: board.text,
+            metadataKey: state.metadataKey,
+          }),
+        ],
+      };
+      messages.splice(index + 1, 0, boardMessage);
+    }
   }
 }
 

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

@@ -172,6 +172,7 @@ export function createTaskSessionManagerHook(
       }
       terminalJobsInjectedByParent.delete(sessionId);
       injectionState.retainedBoardSnapshots.delete(sessionId);
+      injectionState.retainedTailBoards.delete(sessionId);
       taskContextTracker.clearSession(sessionId);
       taskContextTracker.prune(backgroundJobBoard);
       pendingCallTracker.clearSession(sessionId);
@@ -190,6 +191,7 @@ export function createTaskSessionManagerHook(
     shouldManageSession: options.shouldManageSession,
     taskContextTracker,
     retainedBoardSnapshots: new Map(),
+    retainedTailBoards: new Map(),
   };
 
   return {