From 8d953ceee0754d7127d135a6925378881d6ef719 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Sun, 22 Mar 2026 14:55:43 -0400 Subject: [PATCH] refactor: extract CooldownBeltComputer from CooldownSession (Wave 1B, #375) Extract belt computation and agent confidence tracking into a dedicated CooldownBeltComputer class (85L), reducing CooldownSession from 1,225 to 1,201 lines. Per-agent error isolation ensures one bad agent does not poison the batch. Quality: 10 BDD, 17 unit, ArchUnit 14 of 15, mutation 82% (100% covered) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/acceptance/setup.ts | 1 + .../cooldown-belt-computer.feature | 73 ++++ .../cooldown-belt-computer.steps.ts | 315 ++++++++++++++++++ .../cooldown-belt-computer.test.ts | 300 +++++++++++++++++ .../cooldown-belt-computer.ts | 86 +++++ .../cycle-management/cooldown-session.ts | 55 +-- stryker.config.mjs | 1 + 7 files changed, 791 insertions(+), 40 deletions(-) create mode 100644 src/features/cycle-management/cooldown-belt-computer.feature create mode 100644 src/features/cycle-management/cooldown-belt-computer.steps.ts create mode 100644 src/features/cycle-management/cooldown-belt-computer.test.ts create mode 100644 src/features/cycle-management/cooldown-belt-computer.ts diff --git a/src/acceptance/setup.ts b/src/acceptance/setup.ts index df700bc..dd1bda5 100644 --- a/src/acceptance/setup.ts +++ b/src/acceptance/setup.ts @@ -1,5 +1,6 @@ import '@domain/rules/cycle-rules.steps.js'; import '@domain/services/cycle-manager.steps.js'; import '@features/cycle-management/bridge-run-syncer.steps.js'; +import '@features/cycle-management/cooldown-belt-computer.steps.js'; import '@features/cycle-management/cycle-activation-name-resolver.steps.js'; import '@infra/execution/session-bridge.steps.js'; diff --git a/src/features/cycle-management/cooldown-belt-computer.feature b/src/features/cycle-management/cooldown-belt-computer.feature new file mode 100644 index 0000000..e3b429a --- /dev/null +++ b/src/features/cycle-management/cooldown-belt-computer.feature @@ -0,0 +1,73 @@ +Feature: Belt and agent confidence computation during cooldown + After a cooldown phase completes, the practitioner's belt level is + recalculated and per-agent confidence profiles are updated so that + progress tracking always reflects the latest cycle outcomes. + + Background: + Given the cooldown environment is ready + + # ── Belt evaluation ──────────────────────────────────────────── + + Scenario: belt advances when the practitioner levels up + Given belt evaluation is enabled + And the practitioner has earned advancement from "go-kyu" to "yon-kyu" + When belt evaluation runs + Then the belt result shows a level-up to "yon-kyu" + And belt advancement is logged + + Scenario: belt stays steady when no level-up occurs + Given belt evaluation is enabled + And the practitioner remains steady at "go-kyu" + When belt evaluation runs + Then the belt result shows steady at "go-kyu" + And no belt advancement is logged + + Scenario: belt evaluation is skipped when not enabled + Given belt evaluation is not enabled + When belt evaluation runs + Then no belt result is returned + + Scenario: belt evaluation is skipped when project state is unavailable + Given belt evaluation is enabled without project state + When belt evaluation runs + Then no belt result is returned + + Scenario: belt evaluation failure does not abort cooldown + Given belt evaluation is enabled + And belt evaluation will fail with an internal error + When belt evaluation runs + Then no belt result is returned + And a warning is logged about belt computation failure + And cooldown continues normally + + # ── Agent confidence tracking ────────────────────────────────── + + Scenario: confidence is computed for each registered agent + Given agent confidence tracking is enabled + And agents "Alpha" and "Beta" are registered + When agent confidence computation runs + Then confidence is computed for agent "Alpha" + And confidence is computed for agent "Beta" + + Scenario: agent confidence supports legacy kataka aliases + Given agent confidence tracking is enabled via legacy kataka configuration + And agent "Legacy" is registered in the kataka directory + When agent confidence computation runs + Then confidence is computed for agent "Legacy" + + Scenario: agent confidence is skipped when tracking is not enabled + Given agent confidence tracking is not enabled + When agent confidence computation runs + Then no confidence computations occur + + Scenario: agent confidence is skipped when no agent registry is available + Given agent confidence tracking is enabled without an agent registry + When agent confidence computation runs + Then no confidence computations occur + + Scenario: agent confidence failure does not abort cooldown + Given agent confidence tracking is enabled + And the agent registry contains invalid data + When agent confidence computation runs + Then a warning is logged about agent confidence failure + And cooldown continues normally diff --git a/src/features/cycle-management/cooldown-belt-computer.steps.ts b/src/features/cycle-management/cooldown-belt-computer.steps.ts new file mode 100644 index 0000000..97f25eb --- /dev/null +++ b/src/features/cycle-management/cooldown-belt-computer.steps.ts @@ -0,0 +1,315 @@ +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { Given, Then, When, QuickPickleWorld } from 'quickpickle'; +import { expect, vi } from 'vitest'; +import { logger } from '@shared/lib/logger.js'; +import { CooldownBeltComputer, type CooldownBeltDeps } from './cooldown-belt-computer.js'; +import type { BeltComputeResult } from '@features/belt/belt-calculator.js'; +import type { BeltLevel } from '@domain/types/belt.js'; +import type { BeltCalculator } from '@features/belt/belt-calculator.js'; +import type { KataAgentConfidenceCalculator } from '@features/kata-agent/kata-agent-confidence-calculator.js'; + +type ComputeAndStoreFn = BeltCalculator['computeAndStore']; +type ComputeFn = KataAgentConfidenceCalculator['compute']; + +// ── World ──────────────────────────────────────────────────── + +interface CooldownBeltComputerWorld extends QuickPickleWorld { + tmpDir: string; + beltCalculatorSpy?: { computeAndStore: ReturnType> }; + projectStateFile?: string; + agentConfidenceCalculatorSpy?: { compute: ReturnType> }; + katakaConfidenceCalculatorSpy?: { compute: ReturnType> }; + agentDir?: string; + katakaDir?: string; + computer?: CooldownBeltComputer; + beltResult?: BeltComputeResult; + loggerInfoSpy: ReturnType; + loggerWarnSpy: ReturnType; + lastError?: Error; +} + +// ── Helpers ────────────────────────────────────────────────── + +function writeAgentRecord(dir: string, name: string): string { + mkdirSync(dir, { recursive: true }); + const id = randomUUID(); + writeFileSync( + join(dir, `${id}.json`), + JSON.stringify({ + id, + name, + role: 'executor', + skills: [], + createdAt: new Date().toISOString(), + active: true, + }), + ); + return id; +} + +function buildComputer(world: CooldownBeltComputerWorld): CooldownBeltComputer { + const deps: CooldownBeltDeps = { + beltCalculator: world.beltCalculatorSpy, + projectStateFile: world.projectStateFile, + agentConfidenceCalculator: world.agentConfidenceCalculatorSpy, + katakaConfidenceCalculator: world.katakaConfidenceCalculatorSpy, + agentDir: world.agentDir, + katakaDir: world.katakaDir, + }; + return new CooldownBeltComputer(deps); +} + +// ── Background ─────────────────────────────────────────────── + +Given( + 'the cooldown environment is ready', + (world: CooldownBeltComputerWorld) => { + world.tmpDir = mkdtempSync(join(tmpdir(), 'cbc-')); + world.loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + world.loggerWarnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + }, +); + +// ── Given: belt evaluation ─────────────────────────────────── + +Given( + 'belt evaluation is enabled', + (world: CooldownBeltComputerWorld) => { + world.projectStateFile = join(world.tmpDir, 'project-state.json'); + world.beltCalculatorSpy = { computeAndStore: vi.fn() }; + }, +); + +Given( + 'the practitioner has earned advancement from {string} to {string}', + (world: CooldownBeltComputerWorld, from: string, to: string) => { + world.beltCalculatorSpy!.computeAndStore.mockReturnValue({ + belt: to as BeltLevel, + previous: from as BeltLevel, + leveledUp: true, + } as BeltComputeResult); + }, +); + +Given( + 'the practitioner remains steady at {string}', + (world: CooldownBeltComputerWorld, level: string) => { + world.beltCalculatorSpy!.computeAndStore.mockReturnValue({ + belt: level as BeltLevel, + previous: level as BeltLevel, + leveledUp: false, + } as BeltComputeResult); + }, +); + +Given( + 'belt evaluation is not enabled', + (_world: CooldownBeltComputerWorld) => { + // No belt calculator or project state — both left undefined + }, +); + +Given( + 'belt evaluation is enabled without project state', + (world: CooldownBeltComputerWorld) => { + world.beltCalculatorSpy = { computeAndStore: vi.fn() }; + // projectStateFile left undefined + }, +); + +Given( + 'belt evaluation will fail with an internal error', + (world: CooldownBeltComputerWorld) => { + world.beltCalculatorSpy!.computeAndStore.mockImplementation(() => { + throw new Error('Simulated belt failure'); + }); + }, +); + +// ── Given: agent confidence tracking ───────────────────────── + +Given( + 'agent confidence tracking is enabled', + (world: CooldownBeltComputerWorld) => { + world.agentDir = join(world.tmpDir, 'agents'); + mkdirSync(world.agentDir, { recursive: true }); + world.agentConfidenceCalculatorSpy = { compute: vi.fn() }; + }, +); + +Given( + 'agents {string} and {string} are registered', + (world: CooldownBeltComputerWorld, name1: string, name2: string) => { + writeAgentRecord(world.agentDir!, name1); + writeAgentRecord(world.agentDir!, name2); + }, +); + +Given( + 'agent confidence tracking is enabled via legacy kataka configuration', + (world: CooldownBeltComputerWorld) => { + world.katakaDir = join(world.tmpDir, 'kataka-agents'); + mkdirSync(world.katakaDir, { recursive: true }); + world.katakaConfidenceCalculatorSpy = { compute: vi.fn() }; + }, +); + +Given( + 'agent {string} is registered in the kataka directory', + (world: CooldownBeltComputerWorld, name: string) => { + writeAgentRecord(world.katakaDir!, name); + }, +); + +Given( + 'agent confidence tracking is not enabled', + (_world: CooldownBeltComputerWorld) => { + // No calculator or directory — both left undefined + }, +); + +Given( + 'agent confidence tracking is enabled without an agent registry', + (world: CooldownBeltComputerWorld) => { + world.agentConfidenceCalculatorSpy = { compute: vi.fn() }; + // agentDir left undefined + }, +); + +Given( + 'the agent registry contains invalid data', + (world: CooldownBeltComputerWorld) => { + // Point agentDir at a file instead of a directory — KataAgentRegistry will fail + const brokenPath = join(world.tmpDir, 'not-a-directory.json'); + writeFileSync(brokenPath, '{}'); + world.agentDir = brokenPath; + }, +); + +// ── When ───────────────────────────────────────────────────── + +When( + 'belt evaluation runs', + (world: CooldownBeltComputerWorld) => { + world.computer = buildComputer(world); + try { + world.beltResult = world.computer.compute(); + } catch (err) { + world.lastError = err as Error; + } + }, +); + +When( + 'agent confidence computation runs', + (world: CooldownBeltComputerWorld) => { + world.computer = buildComputer(world); + try { + world.computer.computeAgentConfidence(); + } catch (err) { + world.lastError = err as Error; + } + }, +); + +// ── Then: belt assertions ──────────────────────────────────── + +Then( + 'the belt result shows a level-up to {string}', + (world: CooldownBeltComputerWorld, expectedBelt: string) => { + expect(world.lastError).toBeUndefined(); + expect(world.beltResult).toBeDefined(); + expect(world.beltResult!.belt).toBe(expectedBelt); + expect(world.beltResult!.leveledUp).toBe(true); + }, +); + +Then( + 'the belt result shows steady at {string}', + (world: CooldownBeltComputerWorld, expectedBelt: string) => { + expect(world.lastError).toBeUndefined(); + expect(world.beltResult).toBeDefined(); + expect(world.beltResult!.belt).toBe(expectedBelt); + expect(world.beltResult!.leveledUp).toBe(false); + }, +); + +Then( + 'belt advancement is logged', + (world: CooldownBeltComputerWorld) => { + expect(world.loggerInfoSpy).toHaveBeenCalled(); + const msg = world.loggerInfoSpy.mock.calls[0]![0] as string; + expect(msg).toContain('Belt advanced'); + }, +); + +Then( + 'no belt advancement is logged', + (world: CooldownBeltComputerWorld) => { + const infoCalls = world.loggerInfoSpy.mock.calls; + const beltMessages = infoCalls.filter( + (call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('Belt advanced'), + ); + expect(beltMessages).toHaveLength(0); + }, +); + +Then( + 'no belt result is returned', + (world: CooldownBeltComputerWorld) => { + expect(world.lastError).toBeUndefined(); + expect(world.beltResult).toBeUndefined(); + }, +); + +// ── Then: agent confidence assertions ──────────────────────── + +Then( + 'confidence is computed for agent {string}', + (world: CooldownBeltComputerWorld, agentName: string) => { + expect(world.lastError).toBeUndefined(); + const calculator = world.agentConfidenceCalculatorSpy ?? world.katakaConfidenceCalculatorSpy; + expect(calculator).toBeDefined(); + const calls = calculator!.compute.mock.calls as [string, string][]; + const match = calls.find(([, name]) => name === agentName); + expect(match).toBeDefined(); + }, +); + +Then( + 'no confidence computations occur', + (world: CooldownBeltComputerWorld) => { + expect(world.lastError).toBeUndefined(); + if (world.agentConfidenceCalculatorSpy) { + expect(world.agentConfidenceCalculatorSpy.compute).not.toHaveBeenCalled(); + } + if (world.katakaConfidenceCalculatorSpy) { + expect(world.katakaConfidenceCalculatorSpy.compute).not.toHaveBeenCalled(); + } + }, +); + +// ── Then: safety assertions ────────────────────────────────── + +Then( + 'a warning is logged about belt computation failure', + (world: CooldownBeltComputerWorld) => { + expect(world.loggerWarnSpy).toHaveBeenCalled(); + const msg = world.loggerWarnSpy.mock.calls[0]![0] as string; + expect(msg).toContain('Belt computation failed'); + }, +); + +Then( + 'a warning is logged about agent confidence failure', + (world: CooldownBeltComputerWorld) => { + expect(world.loggerWarnSpy).toHaveBeenCalled(); + const msg = world.loggerWarnSpy.mock.calls[0]![0] as string; + expect(msg).toContain('Agent confidence computation failed'); + }, +); + +// 'cooldown continues normally' step is shared — defined in bridge-run-syncer.steps.ts diff --git a/src/features/cycle-management/cooldown-belt-computer.test.ts b/src/features/cycle-management/cooldown-belt-computer.test.ts new file mode 100644 index 0000000..b4e8fbd --- /dev/null +++ b/src/features/cycle-management/cooldown-belt-computer.test.ts @@ -0,0 +1,300 @@ +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { logger } from '@shared/lib/logger.js'; +import { CooldownBeltComputer, type CooldownBeltDeps } from './cooldown-belt-computer.js'; + +function makeDeps(overrides: Partial = {}): CooldownBeltDeps { + return { ...overrides }; +} + +function writeAgentRecord(dir: string, id: string, name: string): void { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, `${id}.json`), + JSON.stringify({ + id, + name, + role: 'executor', + skills: [], + createdAt: new Date().toISOString(), + active: true, + }), + ); +} + +describe('CooldownBeltComputer', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'cbc-unit-')); + }); + + describe('compute()', () => { + it('returns the belt result from the calculator', () => { + const expected = { belt: 'yon-kyu', previous: 'go-kyu', leveledUp: true }; + const computer = new CooldownBeltComputer(makeDeps({ + projectStateFile: join(tmpDir, 'state.json'), + beltCalculator: { computeAndStore: vi.fn(() => expected) }, + })); + + const result = computer.compute(); + + expect(result).toBe(expected); + }); + + it('returns undefined when only beltCalculator is provided (no projectStateFile)', () => { + const computer = new CooldownBeltComputer(makeDeps({ + beltCalculator: { computeAndStore: vi.fn() }, + })); + + expect(computer.compute()).toBeUndefined(); + }); + + it('returns undefined when only projectStateFile is provided (no beltCalculator)', () => { + const computer = new CooldownBeltComputer(makeDeps({ + projectStateFile: join(tmpDir, 'state.json'), + })); + + expect(computer.compute()).toBeUndefined(); + }); + + it('passes the project state file path to computeAndStore', () => { + const stateFile = join(tmpDir, 'state.json'); + const spy = vi.fn(() => ({ belt: 'go-kyu', previous: 'go-kyu', leveledUp: false })); + const computer = new CooldownBeltComputer(makeDeps({ + projectStateFile: stateFile, + beltCalculator: { computeAndStore: spy }, + })); + + computer.compute(); + + expect(spy).toHaveBeenCalledWith(stateFile, expect.anything()); + }); + + it('logs info when belt levels up', () => { + const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const computer = new CooldownBeltComputer(makeDeps({ + projectStateFile: join(tmpDir, 'state.json'), + beltCalculator: { + computeAndStore: vi.fn(() => ({ + belt: 'yon-kyu', + previous: 'go-kyu', + leveledUp: true, + })), + }, + })); + + computer.compute(); + + expect(infoSpy).toHaveBeenCalledWith('Belt advanced: go-kyu → yon-kyu'); + infoSpy.mockRestore(); + }); + + it('does not log info when belt stays steady', () => { + const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const computer = new CooldownBeltComputer(makeDeps({ + projectStateFile: join(tmpDir, 'state.json'), + beltCalculator: { + computeAndStore: vi.fn(() => ({ + belt: 'go-kyu', + previous: 'go-kyu', + leveledUp: false, + })), + }, + })); + + computer.compute(); + + expect(infoSpy).not.toHaveBeenCalled(); + infoSpy.mockRestore(); + }); + + it('warns and returns undefined on computation error', () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const computer = new CooldownBeltComputer(makeDeps({ + projectStateFile: join(tmpDir, 'state.json'), + beltCalculator: { + computeAndStore: vi.fn(() => { throw new Error('disk full'); }), + }, + })); + + const result = computer.compute(); + + expect(result).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Belt computation failed: disk full')); + warnSpy.mockRestore(); + }); + + it('warns with stringified non-Error thrown values', () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const computer = new CooldownBeltComputer(makeDeps({ + projectStateFile: join(tmpDir, 'state.json'), + beltCalculator: { + computeAndStore: vi.fn(() => { throw 'string error'; }), + }, + })); + + computer.compute(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('string error')); + warnSpy.mockRestore(); + }); + }); + + describe('computeAgentConfidence()', () => { + it('calls compute for each registered agent', () => { + const agentDir = join(tmpDir, 'agents'); + const id1 = randomUUID(); + const id2 = randomUUID(); + writeAgentRecord(agentDir, id1, 'Agent-A'); + writeAgentRecord(agentDir, id2, 'Agent-B'); + + const computeSpy = vi.fn(); + const computer = new CooldownBeltComputer(makeDeps({ + agentDir, + agentConfidenceCalculator: { compute: computeSpy }, + })); + + computer.computeAgentConfidence(); + + expect(computeSpy).toHaveBeenCalledTimes(2); + expect(computeSpy).toHaveBeenCalledWith(id1, 'Agent-A'); + expect(computeSpy).toHaveBeenCalledWith(id2, 'Agent-B'); + }); + + it('prefers agentConfidenceCalculator over katakaConfidenceCalculator', () => { + const agentDir = join(tmpDir, 'agents-pref'); + writeAgentRecord(agentDir, randomUUID(), 'X'); + + const canonicalSpy = vi.fn(); + const legacySpy = vi.fn(); + const computer = new CooldownBeltComputer(makeDeps({ + agentDir, + agentConfidenceCalculator: { compute: canonicalSpy }, + katakaConfidenceCalculator: { compute: legacySpy }, + })); + + computer.computeAgentConfidence(); + + expect(canonicalSpy).toHaveBeenCalled(); + expect(legacySpy).not.toHaveBeenCalled(); + }); + + it('prefers agentDir over katakaDir', () => { + const agentDir = join(tmpDir, 'agents-dir-pref'); + const katakaDir = join(tmpDir, 'kataka-dir-pref'); + writeAgentRecord(agentDir, randomUUID(), 'Canonical'); + writeAgentRecord(katakaDir, randomUUID(), 'Legacy'); + + const computeSpy = vi.fn(); + const computer = new CooldownBeltComputer(makeDeps({ + agentDir, + katakaDir, + agentConfidenceCalculator: { compute: computeSpy }, + })); + + computer.computeAgentConfidence(); + + const names = computeSpy.mock.calls.map((c: [string, string]) => c[1]); + expect(names).toContain('Canonical'); + expect(names).not.toContain('Legacy'); + }); + + it('falls back to katakaConfidenceCalculator when canonical is absent', () => { + const katakaDir = join(tmpDir, 'kataka-fallback'); + writeAgentRecord(katakaDir, randomUUID(), 'Fallback'); + + const legacySpy = vi.fn(); + const computer = new CooldownBeltComputer(makeDeps({ + katakaDir, + katakaConfidenceCalculator: { compute: legacySpy }, + })); + + computer.computeAgentConfidence(); + + expect(legacySpy).toHaveBeenCalledWith(expect.any(String), 'Fallback'); + }); + + it('no-ops when calculator is provided but directory is missing', () => { + const computeSpy = vi.fn(); + const computer = new CooldownBeltComputer(makeDeps({ + agentConfidenceCalculator: { compute: computeSpy }, + })); + + computer.computeAgentConfidence(); + + expect(computeSpy).not.toHaveBeenCalled(); + }); + + it('no-ops when directory is provided but calculator is missing', () => { + const agentDir = join(tmpDir, 'agents-no-calc'); + writeAgentRecord(agentDir, randomUUID(), 'Orphan'); + + const computer = new CooldownBeltComputer(makeDeps({ agentDir })); + + // Should not throw + computer.computeAgentConfidence(); + }); + + it('continues computing remaining agents when one agent fails', () => { + const agentDir = join(tmpDir, 'agents-partial-fail'); + const id1 = randomUUID(); + const id2 = randomUUID(); + writeAgentRecord(agentDir, id1, 'Failing'); + writeAgentRecord(agentDir, id2, 'Healthy'); + + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const computeSpy = vi.fn((_id: string, name: string) => { + if (name === 'Failing') throw new Error('agent broke'); + return {} as ReturnType; + }); + + const computer = new CooldownBeltComputer(makeDeps({ + agentDir, + agentConfidenceCalculator: { compute: computeSpy }, + })); + + computer.computeAgentConfidence(); + + expect(computeSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Confidence computation failed for agent "Failing"')); + warnSpy.mockRestore(); + }); + + it('warns and continues when agent registry throws', () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const brokenPath = join(tmpDir, 'broken.json'); + writeFileSync(brokenPath, '{}'); + + const computer = new CooldownBeltComputer(makeDeps({ + agentDir: brokenPath, + agentConfidenceCalculator: { compute: vi.fn() }, + })); + + computer.computeAgentConfidence(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Agent confidence computation failed')); + warnSpy.mockRestore(); + }); + + it('warns with stringified non-Error thrown values', () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const agentDir = join(tmpDir, 'agents-throw'); + writeAgentRecord(agentDir, randomUUID(), 'Thrower'); + + const computer = new CooldownBeltComputer(makeDeps({ + agentDir, + agentConfidenceCalculator: { + compute: vi.fn(() => { throw 'non-error throw'; }), + }, + })); + + computer.computeAgentConfidence(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('non-error throw')); + warnSpy.mockRestore(); + }); + }); +}); diff --git a/src/features/cycle-management/cooldown-belt-computer.ts b/src/features/cycle-management/cooldown-belt-computer.ts new file mode 100644 index 0000000..2bcb1fb --- /dev/null +++ b/src/features/cycle-management/cooldown-belt-computer.ts @@ -0,0 +1,86 @@ +import type { BeltCalculator } from '@features/belt/belt-calculator.js'; +import { loadProjectState, type BeltComputeResult } from '@features/belt/belt-calculator.js'; +import type { KataAgentConfidenceCalculator } from '@features/kata-agent/kata-agent-confidence-calculator.js'; +import { KataAgentRegistry } from '@infra/registries/kata-agent-registry.js'; +import { logger } from '@shared/lib/logger.js'; +import { buildBeltAdvancementMessage } from './cooldown-session.helpers.js'; + +/** + * Dependencies injected into CooldownBeltComputer for testability. + */ +export interface CooldownBeltDeps { + beltCalculator?: Pick; + projectStateFile?: string; + agentConfidenceCalculator?: Pick; + katakaConfidenceCalculator?: Pick; + agentDir?: string; + katakaDir?: string; +} + +/** + * Computes belt advancement and per-agent confidence profiles during cooldown. + * + * Extracted from CooldownSession to isolate optional post-cooldown computations + * from the cooldown orchestration logic. + */ +export class CooldownBeltComputer { + constructor(private readonly deps: CooldownBeltDeps) {} + + /** + * Recompute the practitioner's belt level from current project state. + * Returns the belt result when belt evaluation is fully configured + * (both calculator and project state file present), otherwise undefined. + * + * Non-critical: computation errors are logged as warnings and swallowed + * so that belt evaluation failures do not abort cooldown. + */ + compute(): BeltComputeResult | undefined { + if (!this.deps.beltCalculator || !this.deps.projectStateFile) return undefined; + + try { + const state = loadProjectState(this.deps.projectStateFile); + const beltResult = this.deps.beltCalculator.computeAndStore(this.deps.projectStateFile, state); + const beltAdvanceMessage = buildBeltAdvancementMessage(beltResult); + if (beltAdvanceMessage) { + logger.info(beltAdvanceMessage); + } + return beltResult; + // Stryker disable next-line all: catch block is pure error-reporting — non-critical logging + } catch (err) { + logger.warn(`Belt computation failed: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } + } + + /** + * Recompute confidence profiles for all registered agents. + * Supports legacy kataka aliases (katakaConfidenceCalculator + katakaDir). + * + * Non-critical: computation errors are logged as warnings and swallowed + * so that agent confidence failures do not abort cooldown. + */ + computeAgentConfidence(): void { + const agentConfidenceCalculator = this.deps.agentConfidenceCalculator ?? this.deps.katakaConfidenceCalculator; + const agentDir = this.deps.agentDir ?? this.deps.katakaDir; + if (!agentConfidenceCalculator || !agentDir) return; + + let agents: { id: string; name: string }[]; + try { + const registry = new KataAgentRegistry(agentDir); + agents = registry.list(); + // Stryker disable next-line all: catch block is pure error-reporting — registry load failure + } catch (err) { + logger.warn(`Agent confidence computation failed: ${err instanceof Error ? err.message : String(err)}`); + return; + } + + for (const agent of agents) { + try { + agentConfidenceCalculator.compute(agent.id, agent.name); + // Stryker disable next-line all: catch block is pure error-reporting — per-agent failure + } catch (err) { + logger.warn(`Confidence computation failed for agent "${agent.name}": ${err instanceof Error ? err.message : String(err)}`); + } + } + } +} diff --git a/src/features/cycle-management/cooldown-session.ts b/src/features/cycle-management/cooldown-session.ts index a266676..8b192de 100644 --- a/src/features/cycle-management/cooldown-session.ts +++ b/src/features/cycle-management/cooldown-session.ts @@ -30,12 +30,11 @@ import { type SynthesisProposal, } from '@domain/types/synthesis.js'; import type { BeltCalculator } from '@features/belt/belt-calculator.js'; -import { loadProjectState, type BeltComputeResult } from '@features/belt/belt-calculator.js'; +import type { BeltComputeResult } from '@features/belt/belt-calculator.js'; import type { KataAgentConfidenceCalculator } from '@features/kata-agent/kata-agent-confidence-calculator.js'; -import { KataAgentRegistry } from '@infra/registries/kata-agent-registry.js'; +import { CooldownBeltComputer } from './cooldown-belt-computer.js'; import { buildAgentPerspectiveFromProposals, - buildBeltAdvancementMessage, buildCooldownBudgetUsage, buildExpiryCheckMessages, buildCooldownLearningDrafts, @@ -269,6 +268,7 @@ export class CooldownSession { private readonly frictionAnalyzer: Pick | null; private readonly _nextKeikoProposalGenerator: Pick | null; private readonly bridgeRunSyncer: BridgeRunSyncer; + private readonly beltComputer: CooldownBeltComputer; constructor(deps: CooldownSessionDeps) { this.deps = deps; @@ -283,6 +283,14 @@ export class CooldownSession { runsDir: deps.runsDir, cycleManager: deps.cycleManager, }); + this.beltComputer = new CooldownBeltComputer({ + beltCalculator: deps.beltCalculator, + projectStateFile: deps.projectStateFile, + agentConfidenceCalculator: deps.agentConfidenceCalculator, + katakaConfidenceCalculator: deps.katakaConfidenceCalculator, + agentDir: deps.agentDir, + katakaDir: deps.katakaDir, + }); } private resolveProposalGenerator(deps: CooldownSessionDeps): Pick { @@ -408,39 +416,6 @@ export class CooldownSession { this.runFrictionAnalysis(cycle); } - private computeOptionalBeltResult(): BeltComputeResult | undefined { - if (!this.deps.beltCalculator || !this.deps.projectStateFile) return undefined; - - try { - const state = loadProjectState(this.deps.projectStateFile); - const beltResult = this.deps.beltCalculator.computeAndStore(this.deps.projectStateFile, state); - const beltAdvanceMessage = buildBeltAdvancementMessage(beltResult); - if (beltAdvanceMessage) { - logger.info(beltAdvanceMessage); - } - return beltResult; - // Stryker disable next-line all: catch block is pure error-reporting — non-critical logging - } catch (err) { - logger.warn(`Belt computation failed: ${err instanceof Error ? err.message : String(err)}`); - return undefined; - } - } - - private computeOptionalAgentConfidence(): void { - const agentConfidenceCalculator = this.deps.agentConfidenceCalculator ?? this.deps.katakaConfidenceCalculator; - const agentDir = this.deps.agentDir ?? this.deps.katakaDir; - if (!agentConfidenceCalculator || !agentDir) return; - - try { - const registry = new KataAgentRegistry(agentDir); - for (const agent of registry.list()) { - agentConfidenceCalculator.compute(agent.id, agent.name); - } - } catch (err) { - logger.warn(`Agent confidence computation failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - private writeRunDiary(input: { cycleId: string; cycleName?: string; @@ -579,8 +554,8 @@ export class CooldownSession { try { const phase = this.buildCooldownPhase(cycleId, betOutcomes); this.runCooldownFollowUps(phase.cycle); - const beltResult = this.computeOptionalBeltResult(); - this.computeOptionalAgentConfidence(); + const beltResult = this.beltComputer.compute(); + this.beltComputer.computeAgentConfidence(); this.writeRunDiary({ cycleId, cycleName: phase.cycle.name, @@ -690,8 +665,8 @@ export class CooldownSession { synthesisProposals, }); this.writeOptionalDojoSession(cycleId, cycle.name); - const beltResult = this.computeOptionalBeltResult(); - this.computeOptionalAgentConfidence(); + const beltResult = this.beltComputer.compute(); + this.beltComputer.computeAgentConfidence(); const nextKeikoResult = this.runNextKeikoProposals(cycle); this.deps.cycleManager.updateState(cycleId, 'complete'); diff --git a/stryker.config.mjs b/stryker.config.mjs index 4ab5b04..a1222fe 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -15,6 +15,7 @@ export default { 'src/infrastructure/execution/session-bridge.ts', 'src/cli/commands/execute.ts', 'src/features/cycle-management/bridge-run-syncer.ts', + 'src/features/cycle-management/cooldown-belt-computer.ts', 'src/features/cycle-management/cooldown-session.ts', 'src/features/kata-agent/kata-agent-confidence-calculator.ts', 'src/features/kata-agent/kata-agent-observability-aggregator.ts',