|
|
@@ -532,6 +532,26 @@ describe('BackgroundJobBoard', () => {
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
|
});
|
|
|
|
|
|
+ test('throws in one listener does not prevent subsequent listeners from receiving notification', () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ const order: string[] = [];
|
|
|
+ board.addTerminalStateListener(() => {
|
|
|
+ throw new Error('first listener failed');
|
|
|
+ });
|
|
|
+ board.addTerminalStateListener(() => {
|
|
|
+ order.push('second');
|
|
|
+ });
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'ses_1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'fixer',
|
|
|
+ });
|
|
|
+
|
|
|
+ board.updateStatus({ taskID: 'ses_1', state: 'completed' });
|
|
|
+
|
|
|
+ expect(order).toEqual(['second']);
|
|
|
+ });
|
|
|
+
|
|
|
test('cancelled jobs ignore late non-cancelled terminal statuses', () => {
|
|
|
const board = new BackgroundJobBoard();
|
|
|
board.registerLaunch({
|
|
|
@@ -974,4 +994,276 @@ 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' });
|
|
|
+ // updateStatus({state:'completed'}) triggers trimReusable; the bloated
|
|
|
+ // session exceeds the context budget and is evicted there.
|
|
|
+ // markReconciled is a no-op because the record was already deleted.
|
|
|
+ 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();
|
|
|
+ });
|
|
|
+
|
|
|
+ test('multi-agent isolation: bloated explorer does not evict fixer', () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+
|
|
|
+ // Bloated explorer session
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'ses_exp_bloated',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'explorer',
|
|
|
+ description: 'bloated explorer',
|
|
|
+ });
|
|
|
+ board.addContext('ses_exp_bloated', [
|
|
|
+ { path: '/big.ts', lineCount: 60_000, lastReadAt: 100 },
|
|
|
+ ]);
|
|
|
+ board.updateStatus({ taskID: 'ses_exp_bloated', state: 'completed' });
|
|
|
+ board.markReconciled('ses_exp_bloated');
|
|
|
+
|
|
|
+ // Small fixer session
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'ses_fix_small',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'fixer',
|
|
|
+ description: 'small fixer',
|
|
|
+ });
|
|
|
+ board.addContext('ses_fix_small', [
|
|
|
+ { path: '/small.ts', lineCount: 50, lastReadAt: 200 },
|
|
|
+ ]);
|
|
|
+ board.updateStatus({ taskID: 'ses_fix_small', state: 'completed' });
|
|
|
+ board.markReconciled('ses_fix_small');
|
|
|
+
|
|
|
+ // Explorer bloated session is evicted
|
|
|
+ expect(board.get('ses_exp_bloated')).toBeUndefined();
|
|
|
+ // Fixer session is unaffected (different agent)
|
|
|
+ expect(board.resolveReusable('parent-1', 'fix-1', 'fixer')).toBeDefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ test('session with empty context files is reusable', () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'ses_empty',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'explorer',
|
|
|
+ description: 'no files read',
|
|
|
+ });
|
|
|
+ // No addContext call — contextFiles stays empty
|
|
|
+ board.updateStatus({ taskID: 'ses_empty', state: 'completed' });
|
|
|
+ board.markReconciled('ses_empty');
|
|
|
+
|
|
|
+ expect(
|
|
|
+ board.resolveReusable('parent-1', 'exp-1', 'explorer'),
|
|
|
+ ).toBeDefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ test('formatForPrompt includes context file line counts', () => {
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'ses_1',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'explorer',
|
|
|
+ description: 'test session',
|
|
|
+ });
|
|
|
+ board.addContext('ses_1', [
|
|
|
+ { path: '/src/main.ts', lineCount: 500, lastReadAt: 100 },
|
|
|
+ { path: '/src/util.ts', lineCount: 200, lastReadAt: 200 },
|
|
|
+ ]);
|
|
|
+ board.updateStatus({ taskID: 'ses_1', state: 'completed' });
|
|
|
+ board.markReconciled('ses_1');
|
|
|
+
|
|
|
+ const prompt = board.formatForPrompt('parent-1');
|
|
|
+ expect(prompt).toContain('500 lines');
|
|
|
+ expect(prompt).toContain('200 lines');
|
|
|
+ expect(prompt).toContain('Context read by');
|
|
|
+ });
|
|
|
+ });
|
|
|
});
|