Browse Source

fix(reusable-sessions): gate reusability on context line budget

Adds a context line budget to the reusable-session gate so bloated
sessions (high context read volume) don't get recycled into the
orchestrator prompt.

Changes:
- Add DEFAULT_MAX_CONTEXT_LINES constant (50,000)
- Add maxContextLines to BackgroundJobBoardOptions and
  BackgroundJobsConfigSchema (default 50,000, user-configurable)
- Gate isReusable() on sumContextLines(job) <= maxContextLines
- Evict bloated terminal sessions in trimReusable() before count cap
- Wire config through index.ts with fallback to default

Closes #868
Michael Henke 2 weeks ago
parent
commit
1e38e8346e

+ 1 - 0
src/config/constants.ts

@@ -89,6 +89,7 @@ export const DEFAULT_DISABLED_AGENTS: string[] = ['observer'];
 
 // Background job defaults
 export const DEFAULT_MAX_SESSIONS_PER_AGENT = 2;
+export const DEFAULT_MAX_CONTEXT_LINES = 50_000;
 export const DEFAULT_READ_CONTEXT_MIN_LINES = 10;
 export const DEFAULT_READ_CONTEXT_MAX_FILES = 8;
 export const DEFAULT_MAX_RETAINED_SNAPSHOTS = 20;

+ 1 - 0
src/config/schema.ts

@@ -205,6 +205,7 @@ export const BackgroundJobsConfigSchema = z.object({
       '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),
+  maxContextLines: z.number().int().min(0).max(500_000).default(50_000),
   readContextMinLines: z.number().int().min(0).max(1000).default(10),
   readContextMaxFiles: z.number().int().min(0).max(50).default(8),
   maxRetainedSnapshots: z

+ 3 - 0
src/index.ts

@@ -17,6 +17,7 @@ import {
 import { parseList } from './config/agent-mcps';
 import {
   AGENT_ALIASES,
+  DEFAULT_MAX_CONTEXT_LINES,
   DEFAULT_MAX_RETAINED_SNAPSHOTS,
   DEFAULT_MAX_SESSIONS_PER_AGENT,
   DEFAULT_READ_CONTEXT_MAX_FILES,
@@ -287,6 +288,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       maxReusablePerAgent:
         config.backgroundJobs?.maxSessionsPerAgent ??
         DEFAULT_MAX_SESSIONS_PER_AGENT,
+      maxContextLines:
+        config.backgroundJobs?.maxContextLines ?? DEFAULT_MAX_CONTEXT_LINES,
       readContextMinLines:
         config.backgroundJobs?.readContextMinLines ??
         DEFAULT_READ_CONTEXT_MIN_LINES,

+ 198 - 0
src/utils/background-job-board.test.ts

@@ -974,4 +974,202 @@ describe('BackgroundJobBoard', () => {
       expect(board.field('unknown-1', 'alias')).toBeUndefined();
     });
   });
+
+  describe('context budget gate', () => {
+    test('session under context threshold is reusable', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'ses_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'small session',
+      });
+      board.addContext('ses_1', [
+        { path: '/src/file1.ts', lineCount: 100, lastReadAt: 100 },
+        { path: '/src/file2.ts', lineCount: 200, lastReadAt: 200 },
+      ]);
+      board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+      board.markReconciled('ses_1');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeDefined();
+    });
+
+    test('session over context threshold is not reusable', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'ses_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'bloated session',
+      });
+      board.addContext('ses_1', [
+        { path: '/src/file1.ts', lineCount: 30_000, lastReadAt: 100 },
+        { path: '/src/file2.ts', lineCount: 25_000, lastReadAt: 200 },
+      ]);
+      board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+      board.markReconciled('ses_1');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeUndefined();
+    });
+
+    test('session at 50001 lines is not reusable', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'ses_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'just over threshold',
+      });
+      board.addContext('ses_1', [
+        { path: '/src/file1.ts', lineCount: 50_001, lastReadAt: 100 },
+      ]);
+      board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+      board.markReconciled('ses_1');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeUndefined();
+    });
+
+    test('trimReusable evicts bloated sessions before count cap', () => {
+      const board = new BackgroundJobBoard({
+        maxReusablePerAgent: 2,
+      });
+
+      // Small session 1
+      board.registerLaunch({
+        taskID: 'ses_small_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'small session 1',
+        now: 100,
+      });
+      board.addContext('ses_small_1', [
+        { path: '/src/a.ts', lineCount: 100, lastReadAt: 100 },
+      ]);
+      board.updateStatus({ taskID: 'ses_small_1', state: 'completed' });
+      board.markReconciled('ses_small_1', 200);
+
+      // Small session 2
+      board.registerLaunch({
+        taskID: 'ses_small_2',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'small session 2',
+        now: 300,
+      });
+      board.addContext('ses_small_2', [
+        { path: '/src/b.ts', lineCount: 200, lastReadAt: 300 },
+      ]);
+      board.updateStatus({ taskID: 'ses_small_2', state: 'completed' });
+      board.markReconciled('ses_small_2', 400);
+
+      // Bloated session
+      board.registerLaunch({
+        taskID: 'ses_bloated',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'bloated session',
+        now: 500,
+      });
+      board.addContext('ses_bloated', [
+        { path: '/src/huge.ts', lineCount: 60_000, lastReadAt: 500 },
+      ]);
+      board.updateStatus({ taskID: 'ses_bloated', state: 'completed' });
+      // markReconciled triggers trimReusable; the bloated session exceeds the
+      // context budget and should be evicted
+      board.markReconciled('ses_bloated', 600);
+
+      // Bloated session should be gone; two small sessions survive
+      expect(board.get('ses_bloated')).toBeUndefined();
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeDefined();
+      expect(
+        board.resolveReusable('parent-1', 'exp-2', 'explorer'),
+      ).toBeDefined();
+    });
+
+    test('session at exactly 50000 lines is reusable', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'ses_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'exactly at threshold',
+      });
+      board.addContext('ses_1', [
+        { path: '/src/file1.ts', lineCount: 50_000, lastReadAt: 100 },
+      ]);
+      board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+      board.markReconciled('ses_1');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeDefined();
+    });
+
+    test('custom maxContextLines override works', () => {
+      const board = new BackgroundJobBoard({
+        maxContextLines: 100,
+      });
+
+      // 50 lines — under custom threshold
+      board.registerLaunch({
+        taskID: 'ses_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'under custom limit',
+      });
+      board.addContext('ses_1', [
+        { path: '/src/file1.ts', lineCount: 50, lastReadAt: 100 },
+      ]);
+      board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+      board.markReconciled('ses_1');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeDefined();
+
+      // 101 lines — over custom threshold
+      board.registerLaunch({
+        taskID: 'ses_2',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'over custom limit',
+      });
+      board.addContext('ses_2', [
+        { path: '/src/file2.ts', lineCount: 101, lastReadAt: 200 },
+      ]);
+      board.updateStatus({ taskID: 'ses_2', state: 'completed' });
+      board.markReconciled('ses_2');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-2', 'explorer'),
+      ).toBeUndefined();
+    });
+
+    test('running job with bloated context survives trimReusable', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'running',
+        parentSessionID: 'p',
+        agent: 'explorer',
+      });
+      board.addContext('running', [
+        { path: '/big.ts', lineCount: 200, lastReadAt: 1 },
+      ]);
+      board.registerLaunch({
+        taskID: 'completed',
+        parentSessionID: 'p',
+        agent: 'explorer',
+      });
+      board.updateStatus({ taskID: 'completed', state: 'completed' });
+      expect(board.get('running')).toBeDefined();
+    });
+  });
 });

+ 41 - 7
src/utils/background-job-board.ts

@@ -1,4 +1,5 @@
 import {
+  DEFAULT_MAX_CONTEXT_LINES,
   DEFAULT_MAX_SESSIONS_PER_AGENT,
   DEFAULT_READ_CONTEXT_MAX_FILES,
   DEFAULT_READ_CONTEXT_MIN_LINES,
@@ -46,6 +47,7 @@ export interface BackgroundJobRecord {
 
 export interface BackgroundJobBoardOptions {
   maxReusablePerAgent?: number;
+  maxContextLines?: number;
   readContextMinLines?: number;
   readContextMaxFiles?: number;
 }
@@ -93,12 +95,14 @@ export class BackgroundJobBoard implements BackgroundJobStore {
   private terminalStateListeners: TerminalStateListener[] = [];
 
   private readonly maxReusablePerAgent: number;
+  private readonly maxContextLines: number;
   private readonly readContextMinLines: number;
   private readonly readContextMaxFiles: number;
 
   constructor(options: BackgroundJobBoardOptions = {}) {
     this.maxReusablePerAgent =
       options.maxReusablePerAgent ?? DEFAULT_MAX_SESSIONS_PER_AGENT;
+    this.maxContextLines = options.maxContextLines ?? DEFAULT_MAX_CONTEXT_LINES;
     this.readContextMinLines =
       options.readContextMinLines ?? DEFAULT_READ_CONTEXT_MIN_LINES;
     this.readContextMaxFiles =
@@ -400,7 +404,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     agent?: string,
   ): BackgroundJobRecord | undefined {
     const job = this.resolve(parentSessionID, taskIDOrAlias);
-    if (!job || !isReusable(job)) return undefined;
+    if (!job || !isReusable(job, this.maxContextLines)) return undefined;
     if (agent && job.agent !== agent) return undefined;
     return job;
   }
@@ -484,10 +488,11 @@ export class BackgroundJobBoard implements BackgroundJobStore {
   }
 
   formatForPrompt(parentSessionID: string, _now?: number): string | undefined {
-    const active = this.list(parentSessionID).filter(
+    const jobs = this.list(parentSessionID);
+    const active = jobs.filter(
       (job) => job.state === 'running' || job.terminalUnreconciled,
     );
-    const reusable = this.list(parentSessionID).filter(isReusable);
+    const reusable = jobs.filter((j) => isReusable(j, this.maxContextLines));
 
     if (active.length === 0 && reusable.length === 0) return undefined;
 
@@ -536,10 +541,30 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
   private trimReusable(taskID: string): void {
     const job = this.jobs.get(taskID);
-    if (!job || !isReusable(job)) return;
+    if (!job) return;
+
+    // Evict sessions exceeding context budget before count cap.
+    // Runs regardless of the triggering job's reusability so that a
+    // bloated session cleans up after itself (and its peers) on
+    // completion.
+    for (const entry of this.list(job.parentSessionID)) {
+      if (
+        entry.agent === job.agent &&
+        TERMINAL_STATES.has(entry.state) &&
+        sumContextLines(entry) > this.maxContextLines
+      ) {
+        this.jobs.delete(entry.taskID);
+      }
+    }
+
+    // Only apply the count cap when the triggering job is reusable
+    if (!isReusable(job, this.maxContextLines)) return;
+
     const reusable = this.list(job.parentSessionID)
       .filter(
-        (candidate) => candidate.agent === job.agent && isReusable(candidate),
+        (candidate) =>
+          candidate.agent === job.agent &&
+          isReusable(candidate, this.maxContextLines),
       )
       .sort((a, b) => b.lastUsedAt - a.lastUsedAt);
     for (const stale of reusable.slice(this.maxReusablePerAgent)) {
@@ -590,9 +615,18 @@ export function deriveTaskSessionLabel(input: {
     : `recent ${input.agentType} task`;
 }
 
-function isReusable(job: BackgroundJobRecord): boolean {
+function sumContextLines(record: BackgroundJobRecord): number {
+  return record.contextFiles.reduce((sum, f) => sum + (f.lineCount ?? 0), 0);
+}
+
+function isReusable(
+  job: BackgroundJobRecord,
+  maxContextLines: number,
+): boolean {
   const terminal = job.terminalState ?? terminalStateOf(job.state);
-  return terminal === 'completed' && !job.terminalUnreconciled;
+  if (terminal !== 'completed' || job.terminalUnreconciled) return false;
+
+  return sumContextLines(job) <= maxContextLines;
 }
 
 function terminalStateOf(