Browse Source

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

Alvin 3 weeks ago
parent
commit
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
 reconciliation, and the SDK's current session/todo status remains the liveness
 authority.
 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
 ## 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.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.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.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 |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `fallback.enabled` | boolean | `true` | Enable model failover on timeout/error |
 | `fallback.enabled` | boolean | `true` | Enable model failover on timeout/error |
 | `fallback.timeoutMs` | number | `15000` | Time before aborting and trying next model |
 | `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
 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
 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
 the [Background Orchestration](background-orchestration.md) guide for the concept, defaults, and
 examples.
 examples.
 
 

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

@@ -1020,6 +1020,15 @@
     "backgroundJobs": {
     "backgroundJobs": {
       "type": "object",
       "type": "object",
       "properties": {
       "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": {
         "maxSessionsPerAgent": {
           "default": 2,
           "default": 2,
           "type": "integer",
           "type": "integer",

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

@@ -39,3 +39,22 @@ describe('PluginConfigSchema image_routing', () => {
     expect(result.success).toBe(true);
     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 type InterviewConfig = z.infer<typeof InterviewConfigSchema>;
 
 
 export const BackgroundJobsConfigSchema = z.object({
 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),
   maxSessionsPerAgent: z.number().int().min(1).max(10).default(2),
   readContextMinLines: z.number().int().min(0).max(1000).default(10),
   readContextMinLines: z.number().int().min(0).max(1000).default(10),
   readContextMaxFiles: z.number().int().min(0).max(50).default(8),
   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 { log } from '../../utils/logger';
 import {
 import {
   appendTrailingVolatileMessage,
   appendTrailingVolatileMessage,
+  createTaggedSyntheticPart,
+  isTaggedPart,
   stripTaggedContent,
   stripTaggedContent,
 } from '../cache-safe-injection';
 } from '../cache-safe-injection';
 import type { MessagePart, MessageWithParts } from '../types';
 import type { MessagePart, MessageWithParts } from '../types';
-import { isUserMessageWithParts } from '../types';
+import { isMessageWithParts, isUserMessageWithParts } from '../types';
 import {
 import {
   extractTaskSummary,
   extractTaskSummary,
   formatCancelledTaskStatusOutput,
   formatCancelledTaskStatusOutput,
@@ -36,11 +38,26 @@ const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
 const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
 const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
 
 
 export const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
 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 ────────────────────────────────────────────────────────
 // ── State shape ────────────────────────────────────────────────────────
 
 
 export interface InjectionState {
 export interface InjectionState {
   backgroundJobBoard: BackgroundJobStore;
   backgroundJobBoard: BackgroundJobStore;
+  strategy: 'latest' | 'checkpoint-compatible';
   processedInjectedCompletions: Set<string>;
   processedInjectedCompletions: Set<string>;
   processedInjectedCompletionOrder: string[];
   processedInjectedCompletionOrder: string[];
   terminalJobsInjectedByParent: Map<string, Set<string>>;
   terminalJobsInjectedByParent: Map<string, Set<string>>;
@@ -52,6 +69,7 @@ export interface InjectionState {
     contextFilesForPrompt(taskId: string): ContextFile[];
     contextFilesForPrompt(taskId: string): ContextFile[];
     prune(board: { taskIDs(): Set<string> }): void;
     prune(board: { taskIDs(): Set<string> }): void;
   };
   };
+  retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
 }
 }
 
 
 // ── Helpers ────────────────────────────────────────────────────────────
 // ── Helpers ────────────────────────────────────────────────────────────
@@ -245,12 +263,21 @@ export async function injectBackgroundJobBoard(
 ): Promise<void> {
 ): Promise<void> {
   const messages = Array.isArray(output.messages) ? output.messages : [];
   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) {
   for (let i = messages.length - 1; i >= 0; i -= 1) {
     const message = messages[i];
     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 (!isUserMessageWithParts(message)) continue;
     if (message.info.agent && message.info.agent !== 'orchestrator') return;
     if (message.info.agent && message.info.agent !== 'orchestrator') return;
     if (
     if (
@@ -270,6 +297,11 @@ export async function injectBackgroundJobBoard(
     );
     );
     if (!textPart || isInternalInitiatorPart(textPart)) return;
     if (!textPart || isInternalInitiatorPart(textPart)) return;
 
 
+    if (state.strategy === 'checkpoint-compatible') {
+      injectCheckpointBoard(state, messages, message, reminder);
+      return;
+    }
+
     rememberInjectedTerminalJobs(state, message.info.sessionID);
     rememberInjectedTerminalJobs(state, message.info.sessionID);
     // Append the board as its own trailing message rather than mutating
     // Append the board as its own trailing message rather than mutating
     // an existing user message. In long tool loops the latest user
     // an existing user message. In long tool loops the latest user
@@ -291,3 +323,241 @@ export async function injectBackgroundJobBoard(
     return;
     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 type { BackgroundJobStore } from '../../utils/background-job-store';
 import { log } from '../../utils/logger';
 import { log } from '../../utils/logger';
 import { isFailoverError } from '../foreground-fallback/index';
 import { isFailoverError } from '../foreground-fallback/index';
+import type { RetainedBoardSnapshotState } from './board-injection';
 import type { PendingTaskCall } from './pending-call-tracker';
 import type { PendingTaskCall } from './pending-call-tracker';
 
 
 export async function handleEvent(
 export async function handleEvent(
@@ -72,12 +73,14 @@ export async function handleEvent(
       prune(board: { taskIDs(): Set<string> }): void;
       prune(board: { taskIDs(): Set<string> }): void;
     };
     };
     terminalJobsInjectedByParent: Map<string, Set<string>>;
     terminalJobsInjectedByParent: Map<string, Set<string>>;
+    retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
   },
   },
 ): Promise<void> {
 ): Promise<void> {
   deps.inputWaits.trackInputWait(input.event);
   deps.inputWaits.trackInputWait(input.event);
 
 
   if (input.event.type === 'session.created') {
   if (input.event.type === 'session.created') {
     const info = input.event.properties?.info;
     const info = input.event.properties?.info;
+    if (info?.id) deps.retainedBoardSnapshots.delete(info.id);
     log('[task-session-manager] session.created observed', {
     log('[task-session-manager] session.created observed', {
       sessionID: info?.id,
       sessionID: info?.id,
       parentSessionID: info?.parentID,
       parentSessionID: info?.parentID,
@@ -133,6 +136,7 @@ export async function handleEvent(
   }
   }
 
 
   if (input.event.type === 'server.instance.disposed') {
   if (input.event.type === 'server.instance.disposed') {
+    deps.retainedBoardSnapshots.clear();
     const idleSessionIds = deps.idleReconciler.clearAllTimers();
     const idleSessionIds = deps.idleReconciler.clearAllTimers();
     const continuationSessionIDs = new Set([
     const continuationSessionIDs = new Set([
       ...idleSessionIds,
       ...idleSessionIds,
@@ -291,6 +295,7 @@ export async function handleEvent(
 
 
   deps.continuationTokens.clearContinuation(sessionId);
   deps.continuationTokens.clearContinuation(sessionId);
   deps.inputWaits.clearInputWaits(sessionId);
   deps.inputWaits.clearInputWaits(sessionId);
+  deps.retainedBoardSnapshots.delete(sessionId);
 
 
   log('[task-session-manager] session.deleted observed', {
   log('[task-session-manager] session.deleted observed', {
     sessionID: sessionId,
     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;
       if (options.isFallbackInProgress?.(sessionID)) return;
 
 
       const job = options.backgroundJobBoard.get(sessionID);
       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).
       // Busy after the idle means the session recovered (e.g. FG re-prompt).
       if (
       if (

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

@@ -35,6 +35,7 @@ function createHook(options?: {
   registerSessionAsOrchestrator?: (sessionID: string) => void;
   registerSessionAsOrchestrator?: (sessionID: string) => void;
   readContextMinLines?: number;
   readContextMinLines?: number;
   readContextMaxFiles?: number;
   readContextMaxFiles?: number;
+  strategy?: 'latest' | 'checkpoint-compatible';
   backgroundJobBoard?: BackgroundJobBoard;
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
   sessionStatus?: unknown;
   sessionClient?: Record<string, unknown>;
   sessionClient?: Record<string, unknown>;
@@ -55,6 +56,7 @@ function createHook(options?: {
     } as never,
     } as never,
     {
     {
       maxSessionsPerAgent: 2,
       maxSessionsPerAgent: 2,
+      strategy: options?.strategy,
       readContextMinLines: options?.readContextMinLines,
       readContextMinLines: options?.readContextMinLines,
       readContextMaxFiles: options?.readContextMaxFiles,
       readContextMaxFiles: options?.readContextMaxFiles,
       backgroundJobBoard: options?.backgroundJobBoard,
       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 {
 function boardText(messages: { messages: unknown[] }): string | undefined {
   const last = messages.messages.at(-1) as
   const last = messages.messages.at(-1) as
     | {
     | {
@@ -95,6 +111,10 @@ function boardText(messages: { messages: unknown[] }): string | undefined {
     : undefined;
     : undefined;
 }
 }
 
 
+function isBoardPartForTest(part: { metadata?: Record<string, unknown> }) {
+  return part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true;
+}
+
 async function transformMessages(
 async function transformMessages(
   hook: ReturnType<typeof createTaskSessionManagerHook>,
   hook: ReturnType<typeof createTaskSessionManagerHook>,
   messages: { messages: unknown[] },
   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 () => {
   test('strips existing board parts when no jobs produce a prompt', async () => {
     const { hook } = createHook({
     const { hook } = createHook({
       backgroundJobBoard: new BackgroundJobBoard(),
       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(
 export function createTaskSessionManagerHook(
   _ctx: PluginInput,
   _ctx: PluginInput,
   options: {
   options: {
+    strategy?: 'latest' | 'checkpoint-compatible';
     maxSessionsPerAgent: number;
     maxSessionsPerAgent: number;
     readContextMinLines?: number;
     readContextMinLines?: number;
     readContextMaxFiles?: number;
     readContextMaxFiles?: number;
@@ -155,6 +156,7 @@ export function createTaskSessionManagerHook(
         backgroundJobBoard.clearParent(sessionId);
         backgroundJobBoard.clearParent(sessionId);
       }
       }
       terminalJobsInjectedByParent.delete(sessionId);
       terminalJobsInjectedByParent.delete(sessionId);
+      injectionState.retainedBoardSnapshots.delete(sessionId);
       taskContextTracker.clearSession(sessionId);
       taskContextTracker.clearSession(sessionId);
       taskContextTracker.prune(backgroundJobBoard);
       taskContextTracker.prune(backgroundJobBoard);
       pendingCallTracker.clearSession(sessionId);
       pendingCallTracker.clearSession(sessionId);
@@ -163,6 +165,7 @@ export function createTaskSessionManagerHook(
 
 
   const injectionState: InjectionState = {
   const injectionState: InjectionState = {
     backgroundJobBoard,
     backgroundJobBoard,
+    strategy: options.strategy ?? 'latest',
     processedInjectedCompletions,
     processedInjectedCompletions,
     processedInjectedCompletionOrder,
     processedInjectedCompletionOrder,
     terminalJobsInjectedByParent,
     terminalJobsInjectedByParent,
@@ -170,6 +173,7 @@ export function createTaskSessionManagerHook(
     metadataKey: BACKGROUND_JOB_BOARD_METADATA_KEY,
     metadataKey: BACKGROUND_JOB_BOARD_METADATA_KEY,
     shouldManageSession: options.shouldManageSession,
     shouldManageSession: options.shouldManageSession,
     taskContextTracker,
     taskContextTracker,
+    retainedBoardSnapshots: new Map(),
   };
   };
 
 
   return {
   return {
@@ -294,6 +298,7 @@ export function createTaskSessionManagerHook(
         pendingCallTracker,
         pendingCallTracker,
         taskContextTracker,
         taskContextTracker,
         terminalJobsInjectedByParent,
         terminalJobsInjectedByParent,
+        retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
       }),
       }),
   };
   };
 }
 }

+ 1 - 0
src/index.ts

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

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

@@ -10,17 +10,12 @@ describe('isTruthyEnvValue', () => {
     expect(isTruthyEnvValue(value)).toBe(true);
     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', () => {
 describe('isPluginDisabledByEnv', () => {