Browse Source

Merge pull request #855 from DanielMaly/fix/checkpoint-cache-epochs

Make checkpoint cache epochs configurable
Alvin 1 week ago
parent
commit
37b2bfa982

+ 10 - 8
docs/background-orchestration.md

@@ -346,7 +346,8 @@ For checkpoint-oriented workflows, opt in to the append-only strategy:
 ```jsonc
 {
   "backgroundJobs": {
-    "strategy": "checkpoint-compatible"
+    "strategy": "checkpoint-compatible",
+    "maxRetainedSnapshots": 20
   }
 }
 ```
@@ -356,13 +357,14 @@ 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.
+to 20 snapshots per cache epoch, and is reset when OpenCode reports a session
+boundary or a compacted/rebased message history. `maxRetainedSnapshots` controls
+the epoch size and accepts integers from 1 to 100 (default `20`). When a changed
+snapshot would exceed the configured limit, all retained snapshots are discarded
+and only the new current snapshot is kept. This intentionally creates one cache
+miss at the epoch boundary, after which a fresh run of up to the configured limit
+can accumulate. The cache is lost on plugin restart, so snapshots are not
+restored beyond those present in the current OpenCode message history.
 
 ---
 

+ 2 - 1
docs/configuration.md

@@ -147,7 +147,8 @@ 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 |
+| `backgroundJobs.maxRetainedSnapshots` | integer | `20` | Maximum board snapshots retained per checkpoint cache epoch (1–100). Adding a snapshot beyond the limit starts a new epoch with only the current snapshot, intentionally creating one cache miss |
+| `backgroundJobs.strategy` | `"latest"` \| `"checkpoint-compatible"` | `"latest"` | Board injection strategy. `latest` preserves the current strip-and-replace behavior; `checkpoint-compatible` appends only when the formatted board changes and uses `backgroundJobs.maxRetainedSnapshots` per cache epoch. 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 |

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

@@ -1046,6 +1046,13 @@
           "type": "integer",
           "minimum": 0,
           "maximum": 50
+        },
+        "maxRetainedSnapshots": {
+          "default": 20,
+          "description": "Maximum board snapshots retained per checkpoint cache epoch (1–100). Exceeding the limit starts a new epoch with the current snapshot and intentionally creates one cache miss.",
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 100
         }
       }
     },

+ 1 - 0
src/config/constants.ts

@@ -91,6 +91,7 @@ export const DEFAULT_DISABLED_AGENTS: string[] = ['observer'];
 export const DEFAULT_MAX_SESSIONS_PER_AGENT = 2;
 export const DEFAULT_READ_CONTEXT_MIN_LINES = 10;
 export const DEFAULT_READ_CONTEXT_MAX_FILES = 8;
+export const DEFAULT_MAX_RETAINED_SNAPSHOTS = 20;
 
 export type ImageRouting = 'auto' | 'direct';
 

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

@@ -47,6 +47,7 @@ describe('PluginConfigSchema backgroundJobs', () => {
     expect(result.success).toBe(true);
     if (result.success) {
       expect(result.data.backgroundJobs?.strategy).toBe('latest');
+      expect(result.data.backgroundJobs?.maxRetainedSnapshots).toBe(20);
     }
   });
 
@@ -57,4 +58,33 @@ describe('PluginConfigSchema backgroundJobs', () => {
 
     expect(result.success).toBe(true);
   });
+
+  it('accepts a bounded checkpoint snapshot retention limit', () => {
+    const result = PluginConfigSchema.safeParse({
+      backgroundJobs: { maxRetainedSnapshots: 3 },
+    });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.backgroundJobs?.maxRetainedSnapshots).toBe(3);
+    }
+  });
+
+  it('rejects checkpoint snapshot retention limits outside 1–100', () => {
+    expect(
+      PluginConfigSchema.safeParse({
+        backgroundJobs: { maxRetainedSnapshots: 0 },
+      }).success,
+    ).toBe(false);
+    expect(
+      PluginConfigSchema.safeParse({
+        backgroundJobs: { maxRetainedSnapshots: 101 },
+      }).success,
+    ).toBe(false);
+    expect(
+      PluginConfigSchema.safeParse({
+        backgroundJobs: { maxRetainedSnapshots: 20.5 },
+      }).success,
+    ).toBe(false);
+  });
 });

+ 10 - 0
src/config/schema.ts

@@ -1,4 +1,5 @@
 import { z } from 'zod';
+import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from './constants';
 import { CouncilConfigSchema } from './council-schema';
 
 const MANUAL_AGENT_NAMES = [
@@ -206,6 +207,15 @@ export const BackgroundJobsConfigSchema = z.object({
   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),
+  maxRetainedSnapshots: z
+    .number()
+    .int()
+    .min(1)
+    .max(100)
+    .default(DEFAULT_MAX_RETAINED_SNAPSHOTS)
+    .describe(
+      'Maximum board snapshots retained per checkpoint cache epoch (1–100). Exceeding the limit starts a new epoch with the current snapshot and intentionally creates one cache miss.',
+    ),
 });
 
 export type BackgroundJobsConfig = z.infer<typeof BackgroundJobsConfigSchema>;

+ 5 - 1
src/hooks/cache-safety-harness.test.ts

@@ -8,7 +8,10 @@
  */
 
 import type { PluginConfig } from '../config';
-import { resolveImageRouting } from '../config/constants';
+import {
+  DEFAULT_MAX_RETAINED_SNAPSHOTS,
+  resolveImageRouting,
+} from '../config/constants';
 import { BackgroundJobBoard, createInternalAgentTextPart } from '../utils';
 import { createDisplayNameMentionRewriter } from '../utils/agent-variant';
 import { isVolatileTaggedMessage } from './cache-safe-injection';
@@ -63,6 +66,7 @@ export function createPipeline(): Pipeline {
     } as never,
     {
       maxSessionsPerAgent: 2,
+      maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
       backgroundJobBoard: board,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',

+ 5 - 7
src/hooks/task-session-manager/board-injection.ts

@@ -38,7 +38,6 @@ const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
 const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
 
 export const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
-const MAX_RETAINED_BOARD_SNAPSHOTS = 20;
 
 type RetainedBoardSnapshot = {
   anchorKey: string;
@@ -57,6 +56,7 @@ export type RetainedBoardSnapshotState = {
 
 export interface InjectionState {
   backgroundJobBoard: BackgroundJobStore;
+  maxRetainedSnapshots: number;
   strategy: 'latest' | 'checkpoint-compatible';
   processedInjectedCompletions: Set<string>;
   processedInjectedCompletionOrder: string[];
@@ -345,17 +345,15 @@ function injectCheckpointBoard(
     const encodedSessionID = encodeURIComponent(sessionID);
     const sequence = snapshotState.nextSnapshotSequence;
     snapshotState.nextSnapshotSequence += 1;
+    if (snapshotState.snapshots.length >= state.maxRetainedSnapshots) {
+      // Deliberately start a new cache epoch at the configured boundary.
+      snapshotState.snapshots.length = 0;
+    }
     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);

+ 82 - 10
src/hooks/task-session-manager/index.test.ts

@@ -1,4 +1,5 @@
 import { describe, expect, mock, test } from 'bun:test';
+import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
 import { SessionLifecycle } from '../../hooks/session-lifecycle';
 import {
   BackgroundJobBoard,
@@ -36,6 +37,7 @@ function createHook(options?: {
   readContextMinLines?: number;
   readContextMaxFiles?: number;
   strategy?: 'latest' | 'checkpoint-compatible';
+  maxRetainedSnapshots?: number;
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
   sessionClient?: Record<string, unknown>;
@@ -56,6 +58,8 @@ function createHook(options?: {
     } as never,
     {
       maxSessionsPerAgent: 2,
+      maxRetainedSnapshots:
+        options?.maxRetainedSnapshots ?? DEFAULT_MAX_RETAINED_SNAPSHOTS,
       strategy: options?.strategy,
       readContextMinLines: options?.readContextMinLines,
       readContextMaxFiles: options?.readContextMaxFiles,
@@ -115,6 +119,16 @@ function isBoardPartForTest(part: { metadata?: Record<string, unknown> }) {
   return part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true;
 }
 
+function boardSnapshotIDs(messages: { messages: unknown[] }): string[] {
+  return messages.messages.flatMap((message) =>
+    message.parts.flatMap((part) =>
+      isBoardPartForTest(part) && typeof part.metadata?.snapshotID === 'string'
+        ? [part.metadata.snapshotID]
+        : [],
+    ),
+  );
+}
+
 async function transformMessages(
   hook: ReturnType<typeof createTaskSessionManagerHook>,
   messages: { messages: unknown[] },
@@ -335,6 +349,44 @@ describe('task-session-manager hook', () => {
     expect(messages.messages.at(-2)?.parts[0].text).toBe('second turn');
   });
 
+  test('latest mode ignores maxRetainedSnapshots and replaces the board', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      maxRetainedSnapshots: 1,
+    });
+    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);
+
+    expect(boardSnapshotIDs(messages)).toHaveLength(0);
+    const boardParts = messages.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(messages.messages.at(-1)?.parts[0]).toBe(boardParts[0]);
+  });
+
   test('strips JSON-persisted board parts from earlier messages', async () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
@@ -693,7 +745,7 @@ describe('task-session-manager hook', () => {
     ).toHaveLength(1);
   });
 
-  test('bounds checkpoint history to the configured snapshot limit', async () => {
+  test('starts a new checkpoint cache epoch at the snapshot limit', async () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
       taskID: 'child-1',
@@ -707,7 +759,7 @@ describe('task-session-manager hook', () => {
     });
 
     const history: string[] = ['root'];
-    for (let turn = 0; turn < 21; turn += 1) {
+    for (let turn = 0; turn < 20; turn += 1) {
       board.updateStatus({
         taskID: 'child-1',
         state: turn % 2 === 0 ? 'completed' : 'error',
@@ -716,16 +768,36 @@ describe('task-session-manager hook', () => {
       history.push(`turn-${turn}`);
       const request = createAnchoredMessages('parent-1', history);
       await hook.injectBackgroundJobBoard({}, request);
+
+      if (turn === 19) {
+        expect(boardSnapshotIDs(request)).toHaveLength(20);
+        expect(boardSnapshotIDs(request)[0]).toEndWith(':0');
+        expect(boardSnapshotIDs(request)[19]).toEndWith(':19');
+      }
     }
 
-    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);
+    history.push('epoch-2-turn-1');
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'epoch-2-result-1',
+    });
+    const epochStart = createAnchoredMessages('parent-1', history);
+    await hook.injectBackgroundJobBoard({}, epochStart);
+    expect(boardSnapshotIDs(epochStart)).toHaveLength(1);
+    expect(boardSnapshotIDs(epochStart)[0]).toEndWith(':20');
+
+    history.push('epoch-2-turn-2');
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'error',
+      resultSummary: 'epoch-2-result-2',
+    });
+    const secondEpochRequest = createAnchoredMessages('parent-1', history);
+    await hook.injectBackgroundJobBoard({}, secondEpochRequest);
+    expect(boardSnapshotIDs(secondEpochRequest)).toHaveLength(2);
+    expect(boardSnapshotIDs(secondEpochRequest)[0]).toEndWith(':20');
+    expect(boardSnapshotIDs(secondEpochRequest)[1]).toEndWith(':21');
   });
 
   test('strips existing board parts when no jobs produce a prompt', async () => {

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

@@ -43,6 +43,7 @@ export function createTaskSessionManagerHook(
   options: {
     strategy?: 'latest' | 'checkpoint-compatible';
     maxSessionsPerAgent: number;
+    maxRetainedSnapshots: number;
     readContextMinLines?: number;
     readContextMaxFiles?: number;
     backgroundJobBoard?: BackgroundJobStore;
@@ -165,6 +166,7 @@ export function createTaskSessionManagerHook(
 
   const injectionState: InjectionState = {
     backgroundJobBoard,
+    maxRetainedSnapshots: options.maxRetainedSnapshots,
     strategy: options.strategy ?? 'latest',
     processedInjectedCompletions,
     processedInjectedCompletionOrder,

+ 4 - 0
src/index.ts

@@ -17,6 +17,7 @@ import {
 import { parseList } from './config/agent-mcps';
 import {
   AGENT_ALIASES,
+  DEFAULT_MAX_RETAINED_SNAPSHOTS,
   DEFAULT_MAX_SESSIONS_PER_AGENT,
   DEFAULT_READ_CONTEXT_MAX_FILES,
   DEFAULT_READ_CONTEXT_MIN_LINES,
@@ -324,6 +325,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       maxSessionsPerAgent:
         config.backgroundJobs?.maxSessionsPerAgent ??
         DEFAULT_MAX_SESSIONS_PER_AGENT,
+      maxRetainedSnapshots:
+        config.backgroundJobs?.maxRetainedSnapshots ??
+        DEFAULT_MAX_RETAINED_SNAPSHOTS,
       readContextMinLines:
         config.backgroundJobs?.readContextMinLines ??
         DEFAULT_READ_CONTEXT_MIN_LINES,