Browse Source

Add checkpoint-compatible job board strategy

DanMaly 3 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
 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 |
@@ -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
 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

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

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

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

@@ -30,6 +30,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>;
@@ -50,6 +51,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,
@@ -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 {
 function boardText(messages: { messages: unknown[] }): string | undefined {
   const last = messages.messages.at(-1) as
   const last = messages.messages.at(-1) as
     | {
     | {
@@ -90,6 +106,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[] },
@@ -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 () => {
   test('strips existing board parts when no jobs produce a prompt', async () => {
     const { hook } = createHook({
     const { hook } = createHook({
       backgroundJobBoard: new BackgroundJobBoard(),
       backgroundJobBoard: new BackgroundJobBoard(),

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

@@ -34,11 +34,25 @@ interface TaskArgs {
   task_id?: unknown;
   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 =
 export const BACKGROUND_JOB_BOARD_METADATA_KEY =
   'oh-my-opencode-slim.backgroundJobBoard';
   'oh-my-opencode-slim.backgroundJobBoard';
 const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
 const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
 const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
 const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
 const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
 const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
+const MAX_RETAINED_BOARD_SNAPSHOTS = 20;
 const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
 const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
 
 
 /**
 /**
@@ -96,6 +110,7 @@ function extractTaskSummary(output: string): string | undefined {
 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;
@@ -133,6 +148,7 @@ export function createTaskSessionManagerHook(
   const continuationSessionTokens = new Map<string, symbol>();
   const continuationSessionTokens = new Map<string, symbol>();
   const activeContinuationEvaluations = new Map<string, Set<symbol>>();
   const activeContinuationEvaluations = new Map<string, Set<symbol>>();
   const continuationConsumed = new Set<string>();
   const continuationConsumed = new Set<string>();
+  const retainedBoardSnapshots = new Map<string, RetainedBoardSnapshotState>();
   const idleReconcileDelayMs =
   const idleReconcileDelayMs =
     options.idleReconcileDelayMs ?? IDLE_RECONCILE_DELAY_MS;
     options.idleReconcileDelayMs ?? IDLE_RECONCILE_DELAY_MS;
 
 
@@ -373,6 +389,7 @@ export function createTaskSessionManagerHook(
         backgroundJobBoard.clearParent(sessionId);
         backgroundJobBoard.clearParent(sessionId);
       }
       }
       terminalJobsInjectedByParent.delete(sessionId);
       terminalJobsInjectedByParent.delete(sessionId);
+      retainedBoardSnapshots.delete(sessionId);
       taskContextTracker.clearSession(sessionId);
       taskContextTracker.clearSession(sessionId);
       taskContextTracker.prune(backgroundJobBoard);
       taskContextTracker.prune(backgroundJobBoard);
       pendingCallTracker.clearSession(sessionId);
       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) {
     for (let i = messages.length - 1; i >= 0; i -= 1) {
       const message = messages[i];
       const message = messages[i];
       if (!isMessageWithParts(message)) continue;
       if (!isMessageWithParts(message)) continue;
@@ -595,9 +709,114 @@ export function createTaskSessionManagerHook(
       message.parts = message.parts.filter((part) => !isBoardPart(part));
       message.parts = message.parts.filter((part) => !isBoardPart(part));
       if (hadParts && message.parts.length === 0) messages.splice(i, 1);
       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) {
     for (let i = messages.length - 1; i >= 0; i -= 1) {
       const message = messages[i];
       const message = messages[i];
+      if (
+        options.strategy === 'checkpoint-compatible' &&
+        isMessageWithParts(message) &&
+        isBoardMessage(message)
+      ) {
+        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 (
@@ -610,13 +829,49 @@ export function createTaskSessionManagerHook(
       const reminder = backgroundJobBoard.formatForPrompt(
       const reminder = backgroundJobBoard.formatForPrompt(
         message.info.sessionID,
         message.info.sessionID,
       );
       );
-      if (!reminder) return;
 
 
       const textPart = message.parts.find(
       const textPart = message.parts.find(
         (part) => part.type === 'text' && typeof part.text === 'string',
         (part) => part.type === 'text' && typeof part.text === 'string',
       );
       );
       if (!textPart || isInternalInitiatorPart(textPart)) return;
       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);
       rememberInjectedTerminalJobs(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
@@ -945,6 +1200,7 @@ export function createTaskSessionManagerHook(
     }): Promise<void> => {
     }): Promise<void> => {
       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) 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,
@@ -1001,6 +1257,7 @@ export function createTaskSessionManagerHook(
         for (const sessionID of continuationSessionIDs) {
         for (const sessionID of continuationSessionIDs) {
           clearContinuation(sessionID);
           clearContinuation(sessionID);
         }
         }
+        retainedBoardSnapshots.clear();
         return;
         return;
       }
       }
 
 
@@ -1129,6 +1386,7 @@ export function createTaskSessionManagerHook(
       if (!sessionId) return;
       if (!sessionId) return;
 
 
       clearContinuation(sessionId);
       clearContinuation(sessionId);
+      retainedBoardSnapshots.delete(sessionId);
 
 
       log('[task-session-manager] session.deleted observed', {
       log('[task-session-manager] session.deleted observed', {
         sessionID: sessionId,
         sessionID: sessionId,

+ 1 - 0
src/index.ts

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