Browse Source

Add checkpoint-compatible job board strategy

DanMaly 2 weeks ago
parent
commit
e12e442f3f

+ 29 - 0
docs/background-orchestration.md

@@ -325,6 +325,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 |
@@ -259,7 +260,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

@@ -1046,6 +1046,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

@@ -211,6 +211,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),

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

@@ -30,6 +30,7 @@ function createHook(options?: {
   registerSessionAsOrchestrator?: (sessionID: string) => void;
   readContextMinLines?: number;
   readContextMaxFiles?: number;
+  strategy?: 'latest' | 'checkpoint-compatible';
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
   sessionClient?: Record<string, unknown>;
@@ -50,6 +51,7 @@ function createHook(options?: {
     } as never,
     {
       maxSessionsPerAgent: 2,
+      strategy: options?.strategy,
       readContextMinLines: options?.readContextMinLines,
       readContextMaxFiles: options?.readContextMaxFiles,
       backgroundJobBoard: options?.backgroundJobBoard,
@@ -75,6 +77,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
     | {
@@ -90,6 +106,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[] },
@@ -345,6 +365,284 @@ 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('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(),

+ 266 - 8
src/hooks/task-session-manager/index.ts

@@ -34,11 +34,25 @@ interface TaskArgs {
   task_id?: unknown;
 }
 
+type RetainedBoardSnapshotState = {
+  snapshots: RetainedBoardSnapshot[];
+  nextSnapshotSequence: number;
+  realMessageCount: number;
+  firstRealMessageAnchorKey?: string;
+};
+
+type RetainedBoardSnapshot = {
+  anchorKey: string;
+  id: string;
+  text: string;
+};
+
 export const BACKGROUND_JOB_BOARD_METADATA_KEY =
   'oh-my-opencode-slim.backgroundJobBoard';
 const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
 const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
 const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
+const MAX_RETAINED_BOARD_SNAPSHOTS = 20;
 const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
 
 /**
@@ -96,6 +110,7 @@ function extractTaskSummary(output: string): string | undefined {
 export function createTaskSessionManagerHook(
   _ctx: PluginInput,
   options: {
+    strategy?: 'latest' | 'checkpoint-compatible';
     maxSessionsPerAgent: number;
     readContextMinLines?: number;
     readContextMaxFiles?: number;
@@ -133,6 +148,7 @@ export function createTaskSessionManagerHook(
   const continuationSessionTokens = new Map<string, symbol>();
   const activeContinuationEvaluations = new Map<string, Set<symbol>>();
   const continuationConsumed = new Set<string>();
+  const retainedBoardSnapshots = new Map<string, RetainedBoardSnapshotState>();
   const idleReconcileDelayMs =
     options.idleReconcileDelayMs ?? IDLE_RECONCILE_DELAY_MS;
 
@@ -373,6 +389,7 @@ export function createTaskSessionManagerHook(
         backgroundJobBoard.clearParent(sessionId);
       }
       terminalJobsInjectedByParent.delete(sessionId);
+      retainedBoardSnapshots.delete(sessionId);
       taskContextTracker.clearSession(sessionId);
       taskContextTracker.prune(backgroundJobBoard);
       pendingCallTracker.clearSession(sessionId);
@@ -580,14 +597,111 @@ export function createTaskSessionManagerHook(
     );
   }
 
-  async function injectBackgroundJobBoard(
-    _input: Record<string, never>,
-    output: { messages?: unknown },
-  ): Promise<void> {
-    const messages = Array.isArray(output.messages) ? output.messages : [];
+  function isBoardMessage(message: MessageWithParts): boolean {
+    return message.parts.length > 0 && message.parts.every(isBoardPart);
+  }
+
+  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[]): MessageWithParts[] {
+    return messages.flatMap((message) => {
+      if (!isMessageWithParts(message)) return [];
+      const parts = message.parts.filter((part) => !isBoardPart(part));
+      return parts.length > 0 ? [{ ...message, parts }] : [];
+    });
+  }
+
+  function hasCompacted(
+    previous: RetainedBoardSnapshotState,
+    currentMessages: MessageWithParts[],
+  ): boolean {
+    if (currentMessages.length < previous.realMessageCount) return true;
 
-    // Strip previously injected board content: parts attached to real
-    // messages (legacy placement) and whole synthetic board messages.
+    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(
+    sessionID: string,
+    messages: MessageWithParts[],
+  ): RetainedBoardSnapshotState {
+    const previous = retainedBoardSnapshots.get(sessionID);
+    if (previous && hasCompacted(previous, messages)) {
+      log(
+        '[task-session-manager] resetting checkpoint board history after compaction',
+        {
+          sessionID,
+          previousMessageCount: previous.realMessageCount,
+          currentMessageCount: messages.length,
+        },
+      );
+      retainedBoardSnapshots.delete(sessionID);
+    }
+
+    const current = retainedBoardSnapshots.get(sessionID) ?? {
+      snapshots: [],
+      nextSnapshotSequence: 0,
+      realMessageCount: 0,
+      firstRealMessageAnchorKey: undefined,
+    };
+    const currentAnchorKeys = messageAnchorKeys(messages);
+    current.realMessageCount = messages.length;
+    current.firstRealMessageAnchorKey = currentAnchorKeys[0];
+    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 removeBoardMessages(messages: unknown[]): void {
     for (let i = messages.length - 1; i >= 0; i -= 1) {
       const message = messages[i];
       if (!isMessageWithParts(message)) continue;
@@ -595,9 +709,114 @@ export function createTaskSessionManagerHook(
       message.parts = message.parts.filter((part) => !isBoardPart(part));
       if (hadParts && message.parts.length === 0) messages.splice(i, 1);
     }
+  }
+
+  function createBoardMessage(
+    baseMessage: MessageWithParts,
+    sessionID: string,
+    snapshot: RetainedBoardSnapshot,
+    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: [
+        {
+          type: 'text',
+          synthetic: true,
+          text: snapshot.text,
+          metadata: {
+            [BACKGROUND_JOB_BOARD_METADATA_KEY]: true,
+            sessionID,
+            snapshotID: snapshot.id,
+          },
+        },
+      ],
+    };
+  }
+
+  function replayBoardSnapshots(
+    messages: unknown[],
+    baseMessage: MessageWithParts,
+    sessionID: string,
+    state: RetainedBoardSnapshotState,
+  ): void {
+    const realMessageList = realMessages(messages);
+    const currentAnchorKeys = messageAnchorKeys(realMessageList);
+    const snapshotsByAnchor = new Map<string, RetainedBoardSnapshot[]>();
+    for (const snapshot of state.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;
+
+      const anchorKey = currentAnchorKeys[realMessageIndex];
+      if (!anchorKey) continue;
+      realMessageIndex += 1;
+      for (const snapshot of snapshotsByAnchor.get(anchorKey) ?? []) {
+        rebuiltMessages.push(
+          createBoardMessage(baseMessage, sessionID, snapshot, usedMessageIDs),
+        );
+      }
+    }
+
+    messages.splice(0, messages.length, ...rebuiltMessages);
+  }
+
+  function replayCheckpointBoard(
+    messages: unknown[],
+    baseMessage: MessageWithParts,
+    sessionID: string,
+    state: RetainedBoardSnapshotState,
+  ): void {
+    removeBoardMessages(messages);
+    replayBoardSnapshots(messages, baseMessage, sessionID, state);
+    rememberInjectedTerminalJobs(sessionID);
+  }
+
+  async function injectBackgroundJobBoard(
+    _input: Record<string, never>,
+    output: { messages?: unknown },
+  ): Promise<void> {
+    const messages = Array.isArray(output.messages) ? output.messages : [];
+
+    if (options.strategy !== 'checkpoint-compatible') {
+      // Strip previously injected board content: parts attached to real
+      // messages (legacy placement) and whole synthetic board messages.
+      removeBoardMessages(messages);
+    }
 
     for (let i = messages.length - 1; i >= 0; i -= 1) {
       const message = messages[i];
+      if (
+        options.strategy === 'checkpoint-compatible' &&
+        isMessageWithParts(message) &&
+        isBoardMessage(message)
+      ) {
+        continue;
+      }
       if (!isUserMessageWithParts(message)) continue;
       if (message.info.agent && message.info.agent !== 'orchestrator') return;
       if (
@@ -610,13 +829,49 @@ export function createTaskSessionManagerHook(
       const reminder = backgroundJobBoard.formatForPrompt(
         message.info.sessionID,
       );
-      if (!reminder) return;
 
       const textPart = message.parts.find(
         (part) => part.type === 'text' && typeof part.text === 'string',
       );
       if (!textPart || isInternalInitiatorPart(textPart)) return;
 
+      if (options.strategy === 'checkpoint-compatible') {
+        const sessionID = message.info.sessionID;
+        const currentMessages = realMessages(messages);
+        const state = updateBoardHistoryState(sessionID, currentMessages);
+        const anchorKey = findMessageAnchorKey(currentMessages, message);
+        if (!anchorKey) return;
+
+        if (state.snapshots.at(-1)?.text === reminder) {
+          replayCheckpointBoard(messages, message, sessionID, state);
+          return;
+        }
+
+        if (!reminder) {
+          replayCheckpointBoard(messages, message, sessionID, state);
+          return;
+        }
+
+        const encodedSessionID = encodeURIComponent(sessionID);
+        const sequence = state.nextSnapshotSequence;
+        state.nextSnapshotSequence += 1;
+        state.snapshots.push({
+          anchorKey,
+          id: `oh-my-opencode-slim:background-job-board:${encodedSessionID}:${sequence}`,
+          text: reminder,
+        });
+        if (state.snapshots.length > MAX_RETAINED_BOARD_SNAPSHOTS) {
+          state.snapshots.splice(
+            0,
+            state.snapshots.length - MAX_RETAINED_BOARD_SNAPSHOTS,
+          );
+        }
+        replayCheckpointBoard(messages, message, sessionID, state);
+        return;
+      }
+
+      if (!reminder) return;
+
       rememberInjectedTerminalJobs(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
@@ -945,6 +1200,7 @@ export function createTaskSessionManagerHook(
     }): Promise<void> => {
       if (input.event.type === 'session.created') {
         const info = input.event.properties?.info;
+        if (info?.id) retainedBoardSnapshots.delete(info.id);
         log('[task-session-manager] session.created observed', {
           sessionID: info?.id,
           parentSessionID: info?.parentID,
@@ -1001,6 +1257,7 @@ export function createTaskSessionManagerHook(
         for (const sessionID of continuationSessionIDs) {
           clearContinuation(sessionID);
         }
+        retainedBoardSnapshots.clear();
         return;
       }
 
@@ -1129,6 +1386,7 @@ export function createTaskSessionManagerHook(
       if (!sessionId) return;
 
       clearContinuation(sessionId);
+      retainedBoardSnapshots.delete(sessionId);
 
       log('[task-session-manager] session.deleted observed', {
         sessionID: sessionId,

+ 1 - 0
src/index.ts

@@ -321,6 +321,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,