Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 15 additions & 32 deletions src/features/cycle-management/cooldown-belt-computer.steps.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { mkdtempSync } 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 { CooldownBeltComputer, type CooldownAgentRegistry, 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'];
type ListAgentsFn = CooldownAgentRegistry['list'];

// ── World ────────────────────────────────────────────────────

Expand All @@ -21,39 +22,20 @@ interface CooldownBeltComputerWorld extends QuickPickleWorld {
beltCalculatorSpy?: { computeAndStore: ReturnType<typeof vi.fn<ComputeAndStoreFn>> };
projectStateFile?: string;
agentConfidenceCalculatorSpy?: { compute: ReturnType<typeof vi.fn<ComputeFn>> };
agentDir?: string;
agentRegistry?: { list: ReturnType<typeof vi.fn<ListAgentsFn>> };
computer?: CooldownBeltComputer;
beltResult?: BeltComputeResult;
loggerInfoSpy: ReturnType<typeof vi.fn>;
loggerWarnSpy: ReturnType<typeof vi.fn>;
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,
agentDir: world.agentDir,
agentRegistry: world.agentRegistry,
};
return new CooldownBeltComputer(deps);
}
Expand Down Expand Up @@ -130,17 +112,19 @@ Given(
Given(
'agent confidence tracking is enabled',
(world: CooldownBeltComputerWorld) => {
world.agentDir = join(world.tmpDir, 'agents');
mkdirSync(world.agentDir, { recursive: true });
world.agentConfidenceCalculatorSpy = { compute: vi.fn<ComputeFn>() };
},
);

Given(
'agents {string} and {string} are registered',
(world: CooldownBeltComputerWorld, name1: string, name2: string) => {
writeAgentRecord(world.agentDir!, name1);
writeAgentRecord(world.agentDir!, name2);
world.agentRegistry = {
list: vi.fn<ListAgentsFn>(() => [
{ id: randomUUID(), name: name1 },
{ id: randomUUID(), name: name2 },
]),
};
},
);

Expand All @@ -155,17 +139,16 @@ Given(
'agent confidence tracking is enabled without an agent registry',
(world: CooldownBeltComputerWorld) => {
world.agentConfidenceCalculatorSpy = { compute: vi.fn<ComputeFn>() };
// agentDir left undefined
// agentRegistry 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;
world.agentRegistry = {
list: vi.fn<ListAgentsFn>(() => { throw new Error('Simulated registry failure'); }),
};
},
);

Expand Down
63 changes: 27 additions & 36 deletions src/features/cycle-management/cooldown-belt-computer.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { logger } from '@shared/lib/logger.js';
import { CooldownBeltComputer, type CooldownBeltDeps } from './cooldown-belt-computer.js';
Expand All @@ -9,21 +9,6 @@ function makeDeps(overrides: Partial<CooldownBeltDeps> = {}): 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;

Expand Down Expand Up @@ -145,20 +130,24 @@ describe('CooldownBeltComputer', () => {

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 registryStub = {
list: vi.fn(() => [
{ id: id1, name: 'Agent-A' },
{ id: id2, name: 'Agent-B' },
]),
};

const computeSpy = vi.fn();
const computer = new CooldownBeltComputer(makeDeps({
agentDir,
agentRegistry: registryStub,
agentConfidenceCalculator: { compute: computeSpy },
}));

computer.computeAgentConfidence();

expect(registryStub.list).toHaveBeenCalledOnce();
expect(computeSpy).toHaveBeenCalledTimes(2);
expect(computeSpy).toHaveBeenCalledWith(id1, 'Agent-A');
expect(computeSpy).toHaveBeenCalledWith(id2, 'Agent-B');
Expand Down Expand Up @@ -191,22 +180,25 @@ describe('CooldownBeltComputer', () => {
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');
it('no-ops when registry is provided but calculator is missing', () => {
const registryStub = { list: vi.fn(() => [{ id: randomUUID(), name: 'Orphan' }]) };

const computer = new CooldownBeltComputer(makeDeps({ agentDir }));
const computer = new CooldownBeltComputer(makeDeps({ agentRegistry: registryStub }));

// Should not throw
computer.computeAgentConfidence();
expect(registryStub.list).not.toHaveBeenCalled();
});

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 registryStub = {
list: vi.fn(() => [
{ id: id1, name: 'Failing' },
{ id: id2, name: 'Healthy' },
]),
};

const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const computeSpy = vi.fn((_id: string, name: string) => {
Expand All @@ -215,24 +207,24 @@ describe('CooldownBeltComputer', () => {
});

const computer = new CooldownBeltComputer(makeDeps({
agentDir,
agentRegistry: registryStub,
agentConfidenceCalculator: { compute: computeSpy },
}));

computer.computeAgentConfidence();

expect(registryStub.list).toHaveBeenCalledOnce();
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,
agentRegistry: {
list: vi.fn(() => { throw new Error('registry exploded'); }),
},
agentConfidenceCalculator: { compute: vi.fn() },
}));

Expand All @@ -244,11 +236,10 @@ describe('CooldownBeltComputer', () => {

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,
agentRegistry: {
list: vi.fn(() => [{ id: randomUUID(), name: 'Thrower' }]),
},
agentConfidenceCalculator: {
compute: vi.fn(() => { throw 'non-error throw'; }),
},
Expand Down
16 changes: 8 additions & 8 deletions src/features/cycle-management/cooldown-belt-computer.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
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';

export interface CooldownAgentRegistry {
list(): Array<{ id: string; name: string }>;
}

/**
* Dependencies injected into CooldownBeltComputer for testability.
*/
export interface CooldownBeltDeps {
beltCalculator?: Pick<BeltCalculator, 'computeAndStore'>;
projectStateFile?: string;
agentConfidenceCalculator?: Pick<KataAgentConfidenceCalculator, 'compute'>;
agentDir?: string;
agentRegistry?: Pick<KataAgentRegistry, 'list'>;
agentRegistry?: CooldownAgentRegistry;
}

/**
Expand Down Expand Up @@ -56,15 +58,13 @@ export class CooldownBeltComputer {
*
* Non-critical: computation errors are logged as warnings and swallowed
* so that agent confidence failures do not abort cooldown.
*/
*/
computeAgentConfidence(): void {
if (!this.deps.agentConfidenceCalculator) return;
if (!this.deps.agentRegistry && !this.deps.agentDir) return;
if (!this.deps.agentConfidenceCalculator || !this.deps.agentRegistry) return;

let agents: { id: string; name: string }[];
try {
const registry = this.deps.agentRegistry ?? new KataAgentRegistry(this.deps.agentDir!);
agents = registry.list();
agents = this.deps.agentRegistry.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)}`);
Expand Down
15 changes: 13 additions & 2 deletions src/features/cycle-management/cooldown-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import { FrictionAnalyzer } from '@features/self-improvement/friction-analyzer.j
import type { SynthesisProposal } from '@domain/types/synthesis.js';
import type { BeltCalculator } from '@features/belt/belt-calculator.js';
import type { KataAgentConfidenceCalculator } from '@features/kata-agent/kata-agent-confidence-calculator.js';
import { CooldownBeltComputer } from './cooldown-belt-computer.js';
import { KataAgentRegistry } from '@infra/registries/kata-agent-registry.js';
import { CooldownBeltComputer, type CooldownAgentRegistry } from './cooldown-belt-computer.js';
import { CooldownDiaryWriter } from './cooldown-diary-writer.js';
import { CooldownFollowUpRunner } from './cooldown-follow-up-runner.js';
import { CooldownSynthesisManager } from './cooldown-synthesis-manager.js';
Expand Down Expand Up @@ -127,6 +128,11 @@ export interface CooldownSessionDeps {
* Optional path to the agent registry directory. Required when agentConfidenceCalculator is provided.
*/
agentDir?: string;
/**
* Optional injected agent registry reader for agent confidence computation.
* When omitted and agentDir is set, CooldownSession constructs KataAgentRegistry automatically.
*/
agentRegistry?: CooldownAgentRegistry;
/**
* Optional injected NextKeikoProposalGenerator for testability.
* When omitted and runsDir is set, a NextKeikoProposalGenerator is constructed automatically.
Expand Down Expand Up @@ -188,7 +194,7 @@ export class CooldownSession {
beltCalculator: deps.beltCalculator,
projectStateFile: deps.projectStateFile,
agentConfidenceCalculator: deps.agentConfidenceCalculator,
agentDir: deps.agentDir,
agentRegistry: this.resolveAgentRegistry(deps),
});
this.diaryWriter = new CooldownDiaryWriter({
dojoDir: deps.dojoDir,
Expand Down Expand Up @@ -252,6 +258,11 @@ export class CooldownSession {
return deps.nextKeikoGeneratorDeps ? new NextKeikoProposalGenerator(deps.nextKeikoGeneratorDeps) : null;
}

private resolveAgentRegistry(deps: CooldownSessionDeps): CooldownAgentRegistry | undefined {
if (deps.agentRegistry) return deps.agentRegistry;
return deps.agentDir ? new KataAgentRegistry(deps.agentDir) : undefined;
}

private warnOnIncompleteRuns(incompleteRuns: IncompleteRunInfo[], force: boolean): void {
if (!shouldWarnOnIncompleteRuns(incompleteRuns.length, force)) return;
logger.warn(
Expand Down
16 changes: 5 additions & 11 deletions src/features/cycle-management/cooldown-session.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import type { Run, StageState } from '@domain/types/run-state.js';
import { KnowledgeStore } from '@infra/knowledge/knowledge-store.js';
import { JsonStore } from '@infra/persistence/json-store.js';
import { appendObservation, createRunTree, writeStageState } from '@infra/persistence/run-store.js';
import { KataAgentRegistry } from '@infra/registries/kata-agent-registry.js';
import { SynthesisResultSchema } from '@domain/types/synthesis.js';
import { logger } from '@shared/lib/logger.js';
import {
Expand Down Expand Up @@ -189,22 +188,15 @@ describe('CooldownSession unit seams', () => {
milestoneIssueCount: 0,
})),
};
const agentRegistry = {
list: vi.fn(() => [{ id: randomUUID(), name: 'Unit Agent' }]),
};

writeProjectState(fixture.projectStateFile);
const registry = new KataAgentRegistry(fixture.agentDir);
registry.register({
id: randomUUID(),
name: 'Unit Agent',
role: 'executor',
skills: ['testing'],
createdAt: new Date().toISOString(),
active: true,
});

const session = new CooldownSession({
...fixture.baseDeps,
dojoDir: fixture.dojoDir,
agentDir: fixture.agentDir,
projectStateFile: fixture.projectStateFile,
proposalGenerator,
predictionMatcher,
Expand All @@ -213,6 +205,7 @@ describe('CooldownSession unit seams', () => {
hierarchicalPromoter,
beltCalculator,
agentConfidenceCalculator,
agentRegistry,
dojoSessionBuilder,
nextKeikoProposalGenerator,
ruleRegistry: { getPendingSuggestions: vi.fn(() => []) },
Expand All @@ -232,6 +225,7 @@ describe('CooldownSession unit seams', () => {
fixture.projectStateFile,
expect.objectContaining({ currentBelt: 'mukyu' }),
);
expect(agentRegistry.list).toHaveBeenCalledOnce();
expect(agentConfidenceCalculator.compute).toHaveBeenCalledTimes(1);
expect(dojoSessionBuilder.build).toHaveBeenCalledTimes(1);
expect(nextKeikoProposalGenerator.generate).toHaveBeenCalledTimes(1);
Expand Down
Loading