Procházet zdrojové kódy

Merge pull request #851 from DanielMaly/fix/job-board-cache-safe

Alvin před 2 týdny
rodič
revize
738267bfb2

+ 29 - 0
docs/background-orchestration.md

@@ -335,6 +335,35 @@ plugin restart, the in-memory job board cannot establish prior result
 reconciliation, and the SDK's current session/todo status remains the liveness
 authority.
 
+### Background Job Board Injection
+
+By default, each prompt uses the `latest` board strategy. The hook removes prior
+metadata-tagged board messages and injects the current board snapshot, preserving
+the existing strip-and-replace behavior.
+
+For checkpoint-oriented workflows, opt in to the append-only strategy:
+
+```jsonc
+{
+  "backgroundJobs": {
+    "strategy": "checkpoint-compatible"
+  }
+}
+```
+
+`checkpoint-compatible` preserves prior board snapshots and appends a trailing
+snapshot only when the formatted board changes. Re-running injection with an
+unchanged board does not create a duplicate. This changes board message history
+only; task coordination, storage, terminal reconciliation, and reusable-session
+behavior remain unchanged. The retained snapshot cache is in memory, is limited
+to the latest 20 snapshots per session, and is reset when OpenCode reports a
+session boundary or a compacted/rebased message history. The cache is lost on
+plugin restart, so snapshots are not restored beyond those present in the
+current OpenCode message history. The 20-snapshot cap deliberately bounds
+memory and prompt growth; after eviction, the retained history is no longer a
+complete prefix of the prior request, so checkpoint-cache continuity is not
+guaranteed beyond that point.
+
 ---
 
 ## Startup Behavior

+ 2 - 1
docs/configuration.md

@@ -147,6 +147,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `backgroundJobs.maxSessionsPerAgent` | integer | `2` | Maximum completed/reconciled reusable child sessions per specialist type in the current orchestrator session (1–10) |
 | `backgroundJobs.readContextMinLines` | integer | `10` | Minimum number of lines read from a file before it appears in reusable background-job context (0–1000) |
 | `backgroundJobs.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per reusable child session (0–50) |
+| `backgroundJobs.strategy` | `"latest"` \| `"checkpoint-compatible"` | `"latest"` | Board injection strategy. `latest` preserves the current strip-and-replace behavior; `checkpoint-compatible` keeps the latest 20 prior board snapshots in memory and appends only when the formatted board changes. The cap bounds memory/prompt growth, but after eviction complete checkpoint-prefix continuity is not guaranteed. Cache state resets on compaction/session boundaries and is lost on plugin restart |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `fallback.enabled` | boolean | `true` | Enable model failover on timeout/error |
 | `fallback.timeoutMs` | number | `15000` | Time before aborting and trying next model |
@@ -257,7 +258,7 @@ major is available, the plugin shows a migration command instead.
 
 Background job management is enabled by default and does not need to be present
 in the starter config. Add `backgroundJobs` only if you want to tune how many
-completed/reconciled child-agent sessions are reusable or how much read context is shown. See
+completed/reconciled child-agent sessions are reusable, how much read context is shown, or how board snapshots are injected. See
 the [Background Orchestration](background-orchestration.md) guide for the concept, defaults, and
 examples.
 

+ 9 - 0
oh-my-opencode-slim.schema.json

@@ -1020,6 +1020,15 @@
     "backgroundJobs": {
       "type": "object",
       "properties": {
+        "strategy": {
+          "default": "latest",
+          "description": "Board injection strategy. \"latest\" replaces prior board messages; \"checkpoint-compatible\" preserves them and appends only changed board snapshots.",
+          "type": "string",
+          "enum": [
+            "latest",
+            "checkpoint-compatible"
+          ]
+        },
         "maxSessionsPerAgent": {
           "default": 2,
           "type": "integer",

+ 19 - 0
src/config/schema.test.ts

@@ -39,3 +39,22 @@ describe('PluginConfigSchema image_routing', () => {
     expect(result.success).toBe(true);
   });
 });
+
+describe('PluginConfigSchema backgroundJobs', () => {
+  it('defaults board injection to the legacy latest strategy', () => {
+    const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.backgroundJobs?.strategy).toBe('latest');
+    }
+  });
+
+  it('accepts checkpoint-compatible board injection', () => {
+    const result = PluginConfigSchema.safeParse({
+      backgroundJobs: { strategy: 'checkpoint-compatible' },
+    });
+
+    expect(result.success).toBe(true);
+  });
+});

+ 6 - 0
src/config/schema.ts

@@ -197,6 +197,12 @@ export const InterviewConfigSchema = z.object({
 export type InterviewConfig = z.infer<typeof InterviewConfigSchema>;
 
 export const BackgroundJobsConfigSchema = z.object({
+  strategy: z
+    .enum(['latest', 'checkpoint-compatible'])
+    .default('latest')
+    .describe(
+      'Board injection strategy. "latest" replaces prior board messages; "checkpoint-compatible" preserves them and appends only changed board snapshots.',
+    ),
   maxSessionsPerAgent: z.number().int().min(1).max(10).default(2),
   readContextMinLines: z.number().int().min(0).max(1000).default(10),
   readContextMaxFiles: z.number().int().min(0).max(50).default(8),

+ 274 - 4
src/hooks/task-session-manager/board-injection.ts

@@ -16,10 +16,12 @@ import { isInternalInitiatorPart, parseTaskStatusOutput } from '../../utils';
 import { log } from '../../utils/logger';
 import {
   appendTrailingVolatileMessage,
+  createTaggedSyntheticPart,
+  isTaggedPart,
   stripTaggedContent,
 } from '../cache-safe-injection';
 import type { MessagePart, MessageWithParts } from '../types';
-import { isUserMessageWithParts } from '../types';
+import { isMessageWithParts, isUserMessageWithParts } from '../types';
 import {
   extractTaskSummary,
   formatCancelledTaskStatusOutput,
@@ -36,11 +38,26 @@ const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
 const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
 
 export const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
+const MAX_RETAINED_BOARD_SNAPSHOTS = 20;
+
+type RetainedBoardSnapshot = {
+  anchorKey: string;
+  id: string;
+  text: string;
+};
+
+export type RetainedBoardSnapshotState = {
+  snapshots: RetainedBoardSnapshot[];
+  nextSnapshotSequence: number;
+  realMessageCount: number;
+  firstRealMessageAnchorKey?: string;
+};
 
 // ── State shape ────────────────────────────────────────────────────────
 
 export interface InjectionState {
   backgroundJobBoard: BackgroundJobStore;
+  strategy: 'latest' | 'checkpoint-compatible';
   processedInjectedCompletions: Set<string>;
   processedInjectedCompletionOrder: string[];
   terminalJobsInjectedByParent: Map<string, Set<string>>;
@@ -52,6 +69,7 @@ export interface InjectionState {
     contextFilesForPrompt(taskId: string): ContextFile[];
     prune(board: { taskIDs(): Set<string> }): void;
   };
+  retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
 }
 
 // ── Helpers ────────────────────────────────────────────────────────────
@@ -245,12 +263,21 @@ export async function injectBackgroundJobBoard(
 ): Promise<void> {
   const messages = Array.isArray(output.messages) ? output.messages : [];
 
-  // Strip previously injected board content: parts attached to real
-  // messages (legacy placement) and whole synthetic board messages.
-  stripTaggedContent(messages, state.metadataKey);
+  if (state.strategy === 'latest') {
+    // Strip previously injected board content: parts attached to real
+    // messages (legacy placement) and whole synthetic board messages.
+    stripTaggedContent(messages, state.metadataKey);
+  }
 
   for (let i = messages.length - 1; i >= 0; i -= 1) {
     const message = messages[i];
+    if (
+      isMessageWithParts(message) &&
+      message.parts.length > 0 &&
+      message.parts.every((part) => isTaggedPart(part, state.metadataKey))
+    ) {
+      continue;
+    }
     if (!isUserMessageWithParts(message)) continue;
     if (message.info.agent && message.info.agent !== 'orchestrator') return;
     if (
@@ -270,6 +297,11 @@ export async function injectBackgroundJobBoard(
     );
     if (!textPart || isInternalInitiatorPart(textPart)) return;
 
+    if (state.strategy === 'checkpoint-compatible') {
+      injectCheckpointBoard(state, messages, message, reminder);
+      return;
+    }
+
     rememberInjectedTerminalJobs(state, message.info.sessionID);
     // Append the board as its own trailing message rather than mutating
     // an existing user message. In long tool loops the latest user
@@ -291,3 +323,241 @@ export async function injectBackgroundJobBoard(
     return;
   }
 }
+
+function injectCheckpointBoard(
+  state: InjectionState,
+  messages: unknown[],
+  message: MessageWithParts,
+  reminder: string,
+): void {
+  const sessionID = message.info.sessionID;
+  if (!sessionID) return;
+  const currentMessages = realMessages(messages, state.metadataKey);
+  const snapshotState = updateBoardHistoryState(
+    state,
+    sessionID,
+    currentMessages,
+  );
+  const anchorKey = findMessageAnchorKey(currentMessages, message);
+  if (!anchorKey) return;
+
+  if (snapshotState.snapshots.at(-1)?.text !== reminder && reminder) {
+    const encodedSessionID = encodeURIComponent(sessionID);
+    const sequence = snapshotState.nextSnapshotSequence;
+    snapshotState.nextSnapshotSequence += 1;
+    snapshotState.snapshots.push({
+      anchorKey,
+      id: `oh-my-opencode-slim:background-job-board:${encodedSessionID}:${sequence}`,
+      text: reminder,
+    });
+    if (snapshotState.snapshots.length > MAX_RETAINED_BOARD_SNAPSHOTS) {
+      snapshotState.snapshots.splice(
+        0,
+        snapshotState.snapshots.length - MAX_RETAINED_BOARD_SNAPSHOTS,
+      );
+    }
+  }
+
+  rememberInjectedTerminalJobs(state, sessionID);
+  replayCheckpointBoard(
+    messages,
+    message,
+    sessionID,
+    snapshotState,
+    state.metadataKey,
+  );
+}
+
+function boardHistoryMessageSignature(message: MessageWithParts): string {
+  const text = message.parts
+    .filter(
+      (part) =>
+        part.synthetic !== true &&
+        part.type === 'text' &&
+        typeof part.text === 'string',
+    )
+    .map((part) => part.text)
+    .join('\n');
+  return `${message.info.role}:${message.info.agent ?? ''}:${text}`;
+}
+
+function messageAnchorKeys(messages: MessageWithParts[]): string[] {
+  const occurrences = new Map<string, number>();
+  return messages.map((message) => {
+    const base = message.info.id
+      ? `id:${message.info.id}`
+      : `anonymous:${boardHistoryMessageSignature(message)}`;
+    const occurrence = occurrences.get(base) ?? 0;
+    occurrences.set(base, occurrence + 1);
+    return `${base}:${occurrence}`;
+  });
+}
+
+function realMessages(
+  messages: unknown[],
+  metadataKey: string,
+): MessageWithParts[] {
+  return messages.flatMap((message) => {
+    if (!isMessageWithParts(message)) return [];
+    const parts = message.parts.filter(
+      (part) => !isTaggedPart(part, metadataKey),
+    );
+    return parts.length > 0 ? [{ ...message, parts }] : [];
+  });
+}
+
+function hasCompacted(
+  previous: RetainedBoardSnapshotState,
+  currentMessages: MessageWithParts[],
+): boolean {
+  if (currentMessages.length < previous.realMessageCount) return true;
+
+  const currentAnchorKeys = messageAnchorKeys(currentMessages);
+  return (
+    (currentAnchorKeys[0] !== undefined &&
+      previous.firstRealMessageAnchorKey !== undefined &&
+      currentAnchorKeys[0] !== previous.firstRealMessageAnchorKey) ||
+    previous.snapshots.some(
+      (snapshot) => !currentAnchorKeys.includes(snapshot.anchorKey),
+    )
+  );
+}
+
+function updateBoardHistoryState(
+  state: InjectionState,
+  sessionID: string,
+  messages: MessageWithParts[],
+): RetainedBoardSnapshotState {
+  const previous = state.retainedBoardSnapshots.get(sessionID);
+  if (previous && hasCompacted(previous, messages)) {
+    state.retainedBoardSnapshots.delete(sessionID);
+  }
+
+  const current = state.retainedBoardSnapshots.get(sessionID) ?? {
+    snapshots: [],
+    nextSnapshotSequence: 0,
+    realMessageCount: 0,
+    firstRealMessageAnchorKey: undefined,
+  };
+  const currentAnchorKeys = messageAnchorKeys(messages);
+  current.realMessageCount = messages.length;
+  current.firstRealMessageAnchorKey = currentAnchorKeys[0];
+  state.retainedBoardSnapshots.set(sessionID, current);
+  return current;
+}
+
+function findMessageAnchorKey(
+  messages: MessageWithParts[],
+  message: MessageWithParts,
+): string | undefined {
+  const anchorKeys = messageAnchorKeys(messages);
+  const messageID = message.info.id;
+  if (messageID) {
+    const index = messages.findIndex(
+      (candidate) => candidate.info.id === messageID,
+    );
+    return index >= 0 ? anchorKeys[index] : undefined;
+  }
+
+  const signature = boardHistoryMessageSignature(message);
+  const index = messages.findLastIndex(
+    (candidate) => boardHistoryMessageSignature(candidate) === signature,
+  );
+  return index >= 0 ? anchorKeys[index] : undefined;
+}
+
+function createBoardMessage(
+  baseMessage: MessageWithParts,
+  sessionID: string,
+  snapshot: RetainedBoardSnapshot,
+  metadataKey: string,
+  usedMessageIDs: Set<string>,
+): MessageWithParts {
+  const baseID = snapshot.id;
+  let id = baseID;
+  let collisionIndex = 1;
+  while (usedMessageIDs.has(id)) {
+    id = `${baseID}:collision-${collisionIndex}`;
+    collisionIndex += 1;
+  }
+  usedMessageIDs.add(id);
+  return {
+    info: { ...baseMessage.info, id },
+    parts: [
+      createTaggedSyntheticPart({
+        text: snapshot.text,
+        metadataKey,
+        extraMetadata: { sessionID, snapshotID: snapshot.id },
+      }),
+    ],
+  };
+}
+
+function replayBoardSnapshots(
+  messages: unknown[],
+  baseMessage: MessageWithParts,
+  sessionID: string,
+  snapshotState: RetainedBoardSnapshotState,
+  metadataKey: string,
+): void {
+  const realMessageList = realMessages(messages, metadataKey);
+  const currentAnchorKeys = messageAnchorKeys(realMessageList);
+  const snapshotsByAnchor = new Map<string, RetainedBoardSnapshot[]>();
+  for (const snapshot of snapshotState.snapshots) {
+    const snapshots = snapshotsByAnchor.get(snapshot.anchorKey) ?? [];
+    snapshots.push(snapshot);
+    snapshotsByAnchor.set(snapshot.anchorKey, snapshots);
+  }
+
+  const usedMessageIDs = new Set(
+    messages.flatMap((message) =>
+      isMessageWithParts(message) && message.info.id ? [message.info.id] : [],
+    ),
+  );
+
+  const rebuiltMessages: unknown[] = [];
+  let realMessageIndex = 0;
+  for (const message of messages) {
+    rebuiltMessages.push(message);
+    if (!isMessageWithParts(message) || message.parts.length === 0) continue;
+    if (message.parts.every((part) => isTaggedPart(part, metadataKey))) {
+      continue;
+    }
+
+    const anchorKey = currentAnchorKeys[realMessageIndex];
+    if (!anchorKey) continue;
+    realMessageIndex += 1;
+    for (const snapshot of snapshotsByAnchor.get(anchorKey) ?? []) {
+      rebuiltMessages.push(
+        createBoardMessage(
+          baseMessage,
+          sessionID,
+          snapshot,
+          metadataKey,
+          usedMessageIDs,
+        ),
+      );
+    }
+  }
+
+  messages.splice(0, messages.length, ...rebuiltMessages);
+}
+
+function replayCheckpointBoard(
+  messages: unknown[],
+  baseMessage: MessageWithParts,
+  sessionID: string,
+  snapshotState: RetainedBoardSnapshotState,
+  metadataKey: string,
+): void {
+  stripTaggedContent(messages, metadataKey);
+  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.
+}

+ 5 - 0
src/hooks/task-session-manager/event-router.ts

@@ -8,6 +8,7 @@
 import type { BackgroundJobStore } from '../../utils/background-job-store';
 import { log } from '../../utils/logger';
 import { isFailoverError } from '../foreground-fallback/index';
+import type { RetainedBoardSnapshotState } from './board-injection';
 import type { PendingTaskCall } from './pending-call-tracker';
 
 export async function handleEvent(
@@ -72,12 +73,14 @@ export async function handleEvent(
       prune(board: { taskIDs(): Set<string> }): void;
     };
     terminalJobsInjectedByParent: Map<string, Set<string>>;
+    retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
   },
 ): Promise<void> {
   deps.inputWaits.trackInputWait(input.event);
 
   if (input.event.type === 'session.created') {
     const info = input.event.properties?.info;
+    if (info?.id) deps.retainedBoardSnapshots.delete(info.id);
     log('[task-session-manager] session.created observed', {
       sessionID: info?.id,
       parentSessionID: info?.parentID,
@@ -133,6 +136,7 @@ export async function handleEvent(
   }
 
   if (input.event.type === 'server.instance.disposed') {
+    deps.retainedBoardSnapshots.clear();
     const idleSessionIds = deps.idleReconciler.clearAllTimers();
     const continuationSessionIDs = new Set([
       ...idleSessionIds,
@@ -291,6 +295,7 @@ export async function handleEvent(
 
   deps.continuationTokens.clearContinuation(sessionId);
   deps.inputWaits.clearInputWaits(sessionId);
+  deps.retainedBoardSnapshots.delete(sessionId);
 
   log('[task-session-manager] session.deleted observed', {
     sessionID: sessionId,

+ 1 - 1
src/hooks/task-session-manager/idle-reconciliation.ts

@@ -65,7 +65,7 @@ export function createIdleReconciler(options: {
       if (options.isFallbackInProgress?.(sessionID)) return;
 
       const job = options.backgroundJobBoard.get(sessionID);
-      if (!job || job.state !== 'running') return;
+      if (job?.state !== 'running') return;
 
       // Busy after the idle means the session recovered (e.g. FG re-prompt).
       if (

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

@@ -35,6 +35,7 @@ function createHook(options?: {
   registerSessionAsOrchestrator?: (sessionID: string) => void;
   readContextMinLines?: number;
   readContextMaxFiles?: number;
+  strategy?: 'latest' | 'checkpoint-compatible';
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
   sessionClient?: Record<string, unknown>;
@@ -55,6 +56,7 @@ function createHook(options?: {
     } as never,
     {
       maxSessionsPerAgent: 2,
+      strategy: options?.strategy,
       readContextMinLines: options?.readContextMinLines,
       readContextMaxFiles: options?.readContextMaxFiles,
       backgroundJobBoard: options?.backgroundJobBoard,
@@ -80,6 +82,20 @@ function createMessages(sessionID: string, text = 'user message') {
   };
 }
 
+function createAnchoredMessages(sessionID: string, texts = ['R1']) {
+  return {
+    messages: texts.map((text, index) => ({
+      info: {
+        id: `message-${index}`,
+        role: index === texts.length - 1 ? 'user' : 'assistant',
+        agent: index === texts.length - 1 ? 'orchestrator' : undefined,
+        sessionID,
+      },
+      parts: [{ type: 'text', text }],
+    })),
+  };
+}
+
 function boardText(messages: { messages: unknown[] }): string | undefined {
   const last = messages.messages.at(-1) as
     | {
@@ -95,6 +111,10 @@ function boardText(messages: { messages: unknown[] }): string | undefined {
     : undefined;
 }
 
+function isBoardPartForTest(part: { metadata?: Record<string, unknown> }) {
+  return part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true;
+}
+
 async function transformMessages(
   hook: ReturnType<typeof createTaskSessionManagerHook>,
   messages: { messages: unknown[] },
@@ -350,6 +370,364 @@ describe('task-session-manager hook', () => {
     });
   });
 
+  test('preserves prior board snapshots in checkpoint-compatible mode', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+    });
+    const messages = createMessages('parent-1', 'first turn');
+
+    await hook.injectBackgroundJobBoard({}, messages);
+    messages.messages.push({
+      info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+      parts: [{ type: 'text', text: 'second turn' }],
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'finished mapping',
+    });
+
+    await hook.injectBackgroundJobBoard({}, messages);
+
+    const boardParts = messages.messages.flatMap((message) =>
+      message.parts.filter(
+        (part) => part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
+      ),
+    );
+    expect(boardParts).toHaveLength(2);
+    expect(boardParts[0].text).toContain('running');
+    expect(boardParts[1].text).toContain('completed, unreconciled');
+    expect(messages.messages.at(-1)?.parts[0]).toBe(boardParts[1]);
+  });
+
+  test('does not append an unchanged board in checkpoint-compatible mode', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+    });
+    const messages = createMessages('parent-1');
+
+    await hook.injectBackgroundJobBoard({}, messages);
+    await hook.injectBackgroundJobBoard({}, messages);
+
+    const boardParts = messages.messages.flatMap((message) =>
+      message.parts.filter(
+        (part) => part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
+      ),
+    );
+    expect(boardParts).toHaveLength(1);
+    expect(messages.messages.at(-1)?.parts[0]).toBe(boardParts[0]);
+  });
+
+  test('clears checkpoint snapshots when a session is recreated', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+    });
+
+    await hook.injectBackgroundJobBoard(
+      {},
+      createMessages('parent-1', 'same anchor'),
+    );
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'parent-1' } },
+      },
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'finished',
+    });
+
+    const resetRequest = createMessages('parent-1', 'same anchor');
+    await hook.injectBackgroundJobBoard({}, resetRequest);
+
+    expect(
+      resetRequest.messages.filter((message) =>
+        message.parts.some((part) => isBoardPartForTest(part)),
+      ),
+    ).toHaveLength(1);
+    expect(boardText(resetRequest)).toContain('completed, unreconciled');
+  });
+
+  test('clears checkpoint snapshots when a session is deleted', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+    });
+
+    await hook.injectBackgroundJobBoard(
+      {},
+      createMessages('parent-1', 'same anchor'),
+    );
+    await hook.event({
+      event: {
+        type: 'session.deleted',
+        properties: { sessionID: 'parent-1' },
+      },
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'finished',
+    });
+
+    const resetRequest = createMessages('parent-1', 'same anchor');
+    await hook.injectBackgroundJobBoard({}, resetRequest);
+
+    expect(
+      resetRequest.messages.filter((message) =>
+        message.parts.some((part) => isBoardPartForTest(part)),
+      ),
+    ).toHaveLength(1);
+    expect(boardText(resetRequest)).toContain('completed, unreconciled');
+  });
+
+  test('retains checkpoint snapshots across fresh storage-derived message arrays', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'done',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+      idleReconcileDelayMs: 0,
+    });
+    const firstRequest = createMessages('parent-1', 'first turn');
+
+    await transformMessages(hook, firstRequest);
+    const storedMessages = JSON.parse(
+      JSON.stringify(
+        firstRequest.messages.filter(
+          (message) =>
+            !message.parts?.some(
+              (part) =>
+                part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
+            ),
+        ),
+      ),
+    );
+
+    const secondRequest = { messages: storedMessages };
+    await transformMessages(hook, secondRequest);
+
+    const boardParts = secondRequest.messages.flatMap((message) =>
+      message.parts.filter(
+        (part) => part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
+      ),
+    );
+    expect(boardParts).toHaveLength(1);
+    expect(boardParts[0].text).toContain('completed, unreconciled');
+    expect(boardParts[0].synthetic).toBe(true);
+
+    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,
+    });
+  });
+
+  test('replays snapshots immediately after their anchors in fresh arrays', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+      idleReconcileDelayMs: 0,
+    });
+    const first = createAnchoredMessages('parent-1', ['R1']);
+
+    await hook.injectBackgroundJobBoard({}, first);
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'done',
+    });
+
+    const second = {
+      messages: createAnchoredMessages('parent-1', ['R1', 'A1', 'U2']).messages,
+    };
+    await hook.injectBackgroundJobBoard({}, second);
+    const order = second.messages.flatMap((message) =>
+      message.parts.map((part) => part.text),
+    );
+
+    expect(order).toEqual([
+      'R1',
+      expect.stringContaining('running'),
+      'A1',
+      'U2',
+      expect.stringContaining('completed, unreconciled'),
+    ]);
+
+    const third = { messages: JSON.parse(JSON.stringify(second.messages)) };
+    await hook.injectBackgroundJobBoard({}, third);
+    expect(
+      third.messages.filter((message) =>
+        message.parts.some((part) => isBoardPartForTest(part)),
+      ),
+    ).toHaveLength(2);
+
+    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,
+    });
+  });
+
+  test('reconciles terminal jobs after the first changed checkpoint snapshot', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'done',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+      idleReconcileDelayMs: 0,
+    });
+
+    await hook.injectBackgroundJobBoard({}, createMessages('parent-1'));
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
+
+    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,
+    });
+  });
+
+  test('resets checkpoint snapshots after compaction', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+    });
+    const first = createMessages('parent-1', 'before compaction');
+    await hook.injectBackgroundJobBoard({}, first);
+
+    const compacted = createMessages('parent-1', 'compacted history');
+    await hook.injectBackgroundJobBoard({}, compacted);
+
+    expect(
+      compacted.messages.filter((message) =>
+        message.parts.some((part) => isBoardPartForTest(part)),
+      ),
+    ).toHaveLength(1);
+  });
+
+  test('bounds checkpoint history to the configured snapshot limit', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+    });
+
+    const history: string[] = ['root'];
+    for (let turn = 0; turn < 21; turn += 1) {
+      board.updateStatus({
+        taskID: 'child-1',
+        state: turn % 2 === 0 ? 'completed' : 'error',
+        resultSummary: `result-${turn}`,
+      });
+      history.push(`turn-${turn}`);
+      const request = createAnchoredMessages('parent-1', history);
+      await hook.injectBackgroundJobBoard({}, request);
+    }
+
+    history.push('final');
+    const finalRequest = createAnchoredMessages('parent-1', history);
+    await hook.injectBackgroundJobBoard({}, finalRequest);
+    expect(
+      finalRequest.messages.filter((message) =>
+        message.parts.some((part) => isBoardPartForTest(part)),
+      ),
+    ).toHaveLength(20);
+  });
+
   test('strips existing board parts when no jobs produce a prompt', async () => {
     const { hook } = createHook({
       backgroundJobBoard: new BackgroundJobBoard(),

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

@@ -41,6 +41,7 @@ const IDLE_RECONCILE_DELAY_MS = 2_000;
 export function createTaskSessionManagerHook(
   _ctx: PluginInput,
   options: {
+    strategy?: 'latest' | 'checkpoint-compatible';
     maxSessionsPerAgent: number;
     readContextMinLines?: number;
     readContextMaxFiles?: number;
@@ -155,6 +156,7 @@ export function createTaskSessionManagerHook(
         backgroundJobBoard.clearParent(sessionId);
       }
       terminalJobsInjectedByParent.delete(sessionId);
+      injectionState.retainedBoardSnapshots.delete(sessionId);
       taskContextTracker.clearSession(sessionId);
       taskContextTracker.prune(backgroundJobBoard);
       pendingCallTracker.clearSession(sessionId);
@@ -163,6 +165,7 @@ export function createTaskSessionManagerHook(
 
   const injectionState: InjectionState = {
     backgroundJobBoard,
+    strategy: options.strategy ?? 'latest',
     processedInjectedCompletions,
     processedInjectedCompletionOrder,
     terminalJobsInjectedByParent,
@@ -170,6 +173,7 @@ export function createTaskSessionManagerHook(
     metadataKey: BACKGROUND_JOB_BOARD_METADATA_KEY,
     shouldManageSession: options.shouldManageSession,
     taskContextTracker,
+    retainedBoardSnapshots: new Map(),
   };
 
   return {
@@ -294,6 +298,7 @@ export function createTaskSessionManagerHook(
         pendingCallTracker,
         taskContextTracker,
         terminalJobsInjectedByParent,
+        retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
       }),
   };
 }

+ 1 - 0
src/index.ts

@@ -320,6 +320,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     reflectCommandHook = createReflectCommandHook();
     loopCommandHook = createLoopCommandHook();
     taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
+      strategy: config.backgroundJobs?.strategy ?? 'latest',
       maxSessionsPerAgent:
         config.backgroundJobs?.maxSessionsPerAgent ??
         DEFAULT_MAX_SESSIONS_PER_AGENT,

+ 6 - 11
src/utils/env.test.ts

@@ -10,17 +10,12 @@ describe('isTruthyEnvValue', () => {
     expect(isTruthyEnvValue(value)).toBe(true);
   });
 
-  test.each([
-    undefined,
-    '',
-    '0',
-    'false',
-    'no',
-    'off',
-    'anything',
-  ])('%p is not truthy', (value) => {
-    expect(isTruthyEnvValue(value)).toBe(false);
-  });
+  test.each([undefined, '', '0', 'false', 'no', 'off', 'anything'])(
+    '%p is not truthy',
+    (value) => {
+      expect(isTruthyEnvValue(value)).toBe(false);
+    },
+  );
 });
 
 describe('isPluginDisabledByEnv', () => {