Files
TORCH/test/fleet/evolution.test.mjs

429 lines
24 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { openControlPlane } from '../../src/control-plane/service.mjs';
import { FleetEvolutionService } from '../../src/evolution/service.mjs';
import { analyzeRepository } from '../../src/kernel/analyze.mjs';
import { proposeDomains } from '../../src/kernel/domains.mjs';
import { inspectRepository } from '../../src/kernel/git.mjs';
import { installProject } from '../../src/kernel/install.mjs';
import { callTorchTool } from '../../src/mcp/tools.mjs';
import { createCodexAdapter } from '../../src/adapters/codex.mjs';
import { planAreaUp, startFleet } from '../../src/runtime/lifecycle.mjs';
import { observeProject } from '../../src/observability/snapshot.mjs';
const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname;
test('SCN-domain-runtime-inheritance: new domain proposals follow a Codex-only Fleet default', () => {
const context = fixture({ runtimes: ['codex'] });
const control = openControlPlane({ repositoryRoot: context.root, env: context.env });
try {
const evolution = new FleetEvolutionService({ repositoryRoot: context.root, controlPlane: control });
const count = control.listAgents().length;
for (const [id, runtime] of [['payments', undefined], ['support', 'default']]) {
const change = evolution.proposeDomain({ proposer: 'session-manager',
domain: { id, title: id, scope: ['Recurring specialist work'], not_scope: ['Fleet operations'],
owned_paths: ['src/' + id + '/**'], ...(runtime === undefined ? {} : { runtime }) },
rationale: 'Recurring work needs a coherent owner.',
expectedBenefit: { summary: 'Retain domain context', recurringWork: 'Several milestones',
contextLocality: 'Stable specialist context', coordinationCost: 'One peer interface' },
evidence: ['app.js'],
});
assert.equal(change.domain.runtime, 'codex');
assert.equal(change.state, 'proposed');
}
assert.equal(control.listAgents().length, count, 'proposing does not create sessions');
} finally { control.close(); }
});
function fixture({ boundary = false, runtimes } = {}) {
const root = mkdtempSync(join(tmpdir(), 'torch-evolution-'));
const worktreeParent = mkdtempSync(join(tmpdir(), 'torch-evolution-worktrees-'));
const env = { ...process.env, XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-evolution-state-')) };
execFileSync('git', ['init', '-b', 'main', root]);
execFileSync('git', ['-C', root, 'config', 'user.email', '[email protected]']);
execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']);
writeFileSync(join(root, 'app.js'), 'export const app = true;\n');
execFileSync('git', ['-C', root, 'add', '.']);
execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']);
let repository = inspectRepository(root);
const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) });
if (boundary) {
const template = proposal.domains[0];
proposal.domains = [
{
...template, id: 'alpha', title: 'Alpha', owned_paths: ['app.js', 'src/alpha/**'],
neighbours: ['beta'], evidence: ['app.js'],
},
{
...template, id: 'beta', title: 'Beta', owned_paths: ['src/beta/**'],
neighbours: ['alpha'], evidence: ['app.js'],
},
];
}
proposal.paths = { worktree_parent: worktreeParent };
proposal.review = {
status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [],
};
installProject({ repository, proposal, env, projectId: 'evolution-fixture', runtimes });
execFileSync('git', ['-C', root, 'add', '.torch']);
execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']);
repository = inspectRepository(root);
return { root, env, repository, worktreeParent };
}
function boundaryBenefit() {
return {
summary: 'Align persistent sessions with the recurring implementation boundary.',
recurringWork: 'The same cross-domain changes recur across milestones.',
contextLocality: 'Keep only the context needed by each durable responsibility.',
coordinationCost: 'Reduce recurring handoffs without hiding the migration cost.',
};
}
test('SCN-fleet-boundary-evolution: merge and split proposals are durable, owner-gated, and non-mutating', () => {
const context = fixture({ boundary: true });
const configPath = join(context.root, '.torch', 'torch.yaml');
const rosterPath = join(context.root, '.torch', 'roster.yaml');
const before = { config: readFileSync(configPath, 'utf8'), roster: readFileSync(rosterPath, 'utf8') };
const control = openControlPlane({ repositoryRoot: context.root, env: context.env });
let sequence = 0;
const evolution = new FleetEvolutionService({
repositoryRoot: context.root, controlPlane: control,
clock: () => new Date('2026-09-27T14:00:00Z'), idFactory: () => `BOUNDARY-${++sequence}`,
});
const benefit = boundaryBenefit();
const mergeInput = {
sourceDomains: ['alpha', 'beta'],
resultDomains: [{
id: 'platform', title: 'Platform', kind: 'development',
scope: ['combined alpha and beta implementation'], not_scope: ['Session Manager work'],
owned_paths: ['app.js', 'src/alpha/**', 'src/beta/**'], shared_paths: [], neighbours: [],
required_checks: [], resources: [], runtime: 'claude',
}],
ownershipAssignments: [
{ sourceDomainId: 'alpha', sourcePath: 'app.js', resultDomainId: 'platform' },
{ sourceDomainId: 'alpha', sourcePath: 'src/alpha/**', resultDomainId: 'platform' },
{ sourceDomainId: 'beta', sourcePath: 'src/beta/**', resultDomainId: 'platform' },
],
rationale: 'Alpha and beta now change together and retain mostly identical context.',
expectedBenefit: benefit, evidence: ['TASK-31', 'TASK-38'],
};
assert.throws(
() => evolution.proposeMerge({ ...mergeInput, proposer: 'alpha' }),
(error) => error.code === 'FLEET_CHANGE_AUTHORITY_REQUIRED',
);
assert.throws(
() => evolution.proposeMerge({
...mergeInput, proposer: 'session-manager', ownershipAssignments: mergeInput.ownershipAssignments.slice(1),
}),
(error) => error.code === 'FLEET_OWNERSHIP_ASSIGNMENT_INVALID',
);
const merged = callTorchTool(control, 'torch_propose_domain_merge', {
proposer: 'session-manager', source_domains: mergeInput.sourceDomains,
result_domains: mergeInput.resultDomains.map((domain) => ({
...domain, scope: domain.scope, not_scope: domain.not_scope,
owned_paths: domain.owned_paths, shared_paths: domain.shared_paths,
required_checks: domain.required_checks,
})),
ownership_assignments: mergeInput.ownershipAssignments.map((assignment) => ({
source_domain_id: assignment.sourceDomainId, source_path: assignment.sourcePath,
result_domain_id: assignment.resultDomainId,
})),
rationale: mergeInput.rationale,
expected_benefit: {
summary: benefit.summary, recurring_work: benefit.recurringWork,
context_locality: benefit.contextLocality, coordination_cost: benefit.coordinationCost,
},
evidence: mergeInput.evidence,
}, { actorId: 'session-manager', evolutionService: evolution });
assert.equal(merged.type, 'merge-domains');
assert.equal(merged.proposal.schema, 'torch.dev/fleet-boundary-proposal/v1alpha1');
evolution.approve({ changeId: merged.id, approvedBy: 'fixture-owner' });
const plan = evolution.planActivation(merged.id);
assert.equal(plan.canProceed, false);
assert.equal(plan.blockers.some((blocker) => blocker.type === 'manual-boundary-redesign-required'), true);
writeFileSync(configPath, `${before.config.trimEnd()} \n`);
assert.equal(evolution.planActivation(merged.id).blockers
.some((blocker) => blocker.type === 'configuration-changed'), true);
writeFileSync(configPath, before.config);
assert.throws(
() => evolution.activate({ changeId: merged.id, approvedBy: 'fixture-owner' }),
(error) => error.code === 'FLEET_BOUNDARY_MIGRATION_REQUIRED',
);
assert.equal(readFileSync(configPath, 'utf8'), before.config);
assert.equal(readFileSync(rosterPath, 'utf8'), before.roster);
assert.deepEqual(control.listAgents().map((agent) => agent.areaId).sort(), ['alpha', 'beta', 'session-manager']);
control.close();
const splitPath = join(context.root, 'split-proposal.json');
writeFileSync(splitPath, `${JSON.stringify({
sourceDomains: ['alpha'],
resultDomains: [
{
id: 'alpha-app', title: 'Alpha app', scope: ['application entrypoint'], not_scope: ['alpha internals'],
owned_paths: ['app.js'], shared_paths: [], neighbours: ['alpha-internals'], required_checks: [], resources: [], runtime: 'claude',
},
{
id: 'alpha-internals', title: 'Alpha internals', scope: ['alpha internals'], not_scope: ['application entrypoint'],
owned_paths: ['src/alpha/**'], shared_paths: [], neighbours: ['alpha-app'], required_checks: [], resources: [], runtime: 'claude',
},
],
ownershipAssignments: [
{ sourceDomainId: 'alpha', sourcePath: 'app.js', resultDomainId: 'alpha-app' },
{ sourceDomainId: 'alpha', sourcePath: 'src/alpha/**', resultDomainId: 'alpha-internals' },
],
rationale: 'Alpha now contains two recurring responsibilities with different context.',
expectedBenefit: benefit, evidence: ['TASK-44', 'ownership review'],
}, null, 2)}\n`);
const run = (args) => spawnSync(process.execPath, [CLI, ...args], {
cwd: context.root, env: context.env, encoding: 'utf8',
});
const proposed = run(['fleet', 'propose-split', '--from', 'session-manager', '--proposal', splitPath, '--json']);
assert.equal(proposed.status, 0, proposed.stderr || proposed.stdout);
const split = JSON.parse(proposed.stdout);
assert.equal(split.type, 'split-domain');
const refused = run(['fleet', 'reject', '--change', split.id, '--by', 'fixture-owner', '--reason', 'Wait for milestone.', '--json']);
assert.equal(refused.status, 2);
assert.equal(JSON.parse(refused.stdout).error, 'APPROVAL_REQUIRED');
const rejected = run(['fleet', 'reject', '--change', split.id, '--by', 'fixture-owner', '--reason', 'Wait for milestone.', '--yes', '--json']);
assert.equal(rejected.status, 0, rejected.stderr || rejected.stdout);
assert.equal(JSON.parse(rejected.stdout).state, 'rejected');
assert.equal(JSON.parse(rejected.stdout).rejectionReason, 'Wait for milestone.');
const reopened = openControlPlane({ repositoryRoot: context.root, env: context.env });
const persisted = new FleetEvolutionService({ repositoryRoot: context.root, controlPlane: reopened }).get(merged.id);
assert.equal(persisted.type, 'merge-domains');
assert.equal(persisted.proposal.resultDomains[0].id, 'platform');
assert.equal(new FleetEvolutionService({ repositoryRoot: context.root, controlPlane: reopened })
.get(split.id).rejectionReason, 'Wait for milestone.');
reopened.close();
});
test('SCN-fleet-evolution: the manager proposes and owner activates a newly justified persistent domain', () => {
const context = fixture({ runtimes: ['claude', 'codex'] });
const control = openControlPlane({ repositoryRoot: context.root, env: context.env });
const evolution = new FleetEvolutionService({
repositoryRoot: context.root,
controlPlane: control,
clock: () => new Date('2026-09-27T12:00:00Z'),
idFactory: () => 'CHANGE-1',
});
const worker = control.listAgents().find((agent) => agent.areaId !== 'session-manager').areaId;
control.requestHandoff({
sender: worker, path: 'src/payments/intents.js',
reason: 'No current domain owns the payment intent boundary.',
});
control.requestHandoff({
sender: worker, path: 'src/payments/settlement.js',
reason: 'Settlement work again has no coherent owner.',
});
assert.throws(
() => evolution.assessDomainNeeds({ assessor: worker }),
(error) => error.code === 'FLEET_CHANGE_AUTHORITY_REQUIRED',
);
const assessment = callTorchTool(control, 'torch_assess_fleet_evolution', {}, {
actorId: 'session-manager', evolutionService: evolution,
});
assert.equal(assessment.recommendation.action, 'consider-new-domain');
assert.equal(assessment.signals[0].code, 'RECURRING_UNOWNED_PATH_BOUNDARY');
assert.equal(assessment.signals[0].boundary, 'src/payments');
assert.equal(assessment.mutationPerformed, false);
assert.equal(evolution.list().length, 0, 'assessment must not create a Fleet change');
const request = {
domain: {
id: 'payments', title: 'Payment systems', kind: 'development',
scope: ['payment intent lifecycle and settlement verification'],
not_scope: ['checkout presentation'], owned_paths: ['src/payments/**'],
shared_paths: ['src/contracts/payment-schema.js'], neighbours: [worker],
required_checks: [], resources: [], runtime: 'codex', model: 'gpt-test',
},
rationale: 'Repeated payment work has no coherent owner in the current Fleet.',
expectedBenefit: {
summary: 'Retain payment-domain context across recurring settlement work.',
recurringWork: 'Three queued milestones and ongoing incident ownership.',
contextLocality: 'Avoid reloading payment invariants into unrelated sessions.',
coordinationCost: 'One explicit API boundary with the existing core domain.',
},
evidence: ['docs/product-spec.md:42', 'TASK-17', 'TASK-23'],
};
assert.throws(
() => evolution.proposeDomain({ ...request, proposer: worker }),
(error) => error.code === 'FLEET_CHANGE_AUTHORITY_REQUIRED',
);
const proposed = callTorchTool(control, 'torch_propose_domain', {
proposer: 'session-manager', domain: request.domain, rationale: request.rationale,
expected_benefit: {
summary: request.expectedBenefit.summary,
recurring_work: request.expectedBenefit.recurringWork,
context_locality: request.expectedBenefit.contextLocality,
coordination_cost: request.expectedBenefit.coordinationCost,
},
evidence: request.evidence,
}, { actorId: 'session-manager', evolutionService: evolution });
assert.equal(proposed.state, 'proposed');
assert.equal(control.listAgents().some((agent) => agent.areaId === 'payments'), false);
assert.throws(
() => evolution.activate({ changeId: proposed.id, approvedBy: 'fixture-owner' }),
(error) => error.code === 'FLEET_CHANGE_AUTHORITY_REQUIRED',
);
const approved = evolution.approve({ changeId: proposed.id, approvedBy: 'fixture-owner' });
assert.equal(approved.state, 'approved');
const observed = observeProject({ repositoryRoot: context.root, env: context.env });
assert.equal(observed.fleetChanges[0].domainId, 'payments');
assert.equal(observed.fleetChanges[0].state, 'approved');
const plan = evolution.planActivation(proposed.id);
assert.equal(plan.canProceed, true, JSON.stringify(plan.blockers));
assert.equal(plan.mutationPerformed, false);
const activated = evolution.activate({ changeId: proposed.id, approvedBy: 'fixture-owner' });
assert.equal(activated.state, 'active');
assert.equal(activated.identity.areaId, 'payments');
assert.equal(activated.identity.state, 'offline');
assert.equal(activated.identity.model, 'gpt-test');
assert.equal(existsSync(activated.worktree), true);
const paymentPromptPath = join(activated.worktree, '.torch', 'prompts', 'payments.md');
assert.equal(existsSync(paymentPromptPath), true);
const paymentPrompt = readFileSync(paymentPromptPath, 'utf8');
assert.match(paymentPrompt, /## Owned paths[\s\S]*src\/payments\/\*\*/);
assert.match(paymentPrompt, /## Authority/);
assert.match(paymentPrompt, /## Initial backlog/);
assert.equal(execFileSync('git', ['-C', activated.worktree, 'status', '--porcelain'], { encoding: 'utf8' }), '');
assert.equal(execFileSync('git', ['-C', context.root, 'status', '--porcelain'], { encoding: 'utf8' }), '');
const roster = JSON.parse(readFileSync(join(context.root, '.torch', 'roster.yaml'), 'utf8'));
assert.equal(roster.areas.find((area) => area.id === worker).neighbours.includes('payments'), true);
assert.equal(roster.areas.find((area) => area.id === 'payments').neighbours.includes(worker), true);
assert.match(execFileSync('git', ['-C', context.root, 'log', '-1', '--pretty=%s'], { encoding: 'utf8' }), /add payments domain/);
const adapter = createCodexAdapter({ executable: '/opt/codex/bin/codex' });
const adapters = new Map([['codex', adapter]]);
const startPlan = planAreaUp({
repositoryRoot: context.root, controlPlane: control, areaId: 'payments', adapters, fresh: true,
});
assert.equal(startPlan.canProceed, true, JSON.stringify(startPlan.blockers));
assert.deepEqual(startPlan.actions.map((action) => action.areaId), ['payments']);
const started = startFleet({
plan: startPlan, controlPlane: control, adapters,
executor: () => ({
status: 0, stdout: '{"type":"thread.started","thread_id":"payments-thread"}\n{"type":"turn.completed"}\n',
}),
});
assert.equal(started.started[0].runtimeSessionId, 'payments-thread');
assert.equal(control.identity('payments').state, 'idle');
control.close();
const reopened = openControlPlane({ repositoryRoot: context.root, env: context.env });
assert.equal(reopened.identity('payments').areaId, 'payments');
const persisted = new FleetEvolutionService({ repositoryRoot: context.root, controlPlane: reopened }).get(proposed.id);
assert.equal(persisted.activationCommit, activated.activationCommit);
reopened.close();
});
test('SCN-cli-fleet-evolution: public CLI separates manager proposal, owner approval, plan, and activation', () => {
const context = fixture();
const inputPath = join(context.root, 'domain-change.json');
writeFileSync(inputPath, `${JSON.stringify({
domain: {
id: 'security', title: 'Security engineering', scope: ['threat modeling and security checks'],
not_scope: ['general feature implementation'], owned_paths: ['security/**'],
shared_paths: [], neighbours: [], required_checks: [], runtime: 'claude',
},
rationale: 'Recurring threat-model and audit work lacks a durable owner.',
expectedBenefit: {
summary: 'Keep security assumptions focused and reviewable.',
recurringWork: 'Security review recurs at each release.',
contextLocality: 'Retain threat models instead of reloading them into feature sessions.',
coordinationCost: 'The Session Manager coordinates explicit review requests.',
},
evidence: ['docs/security-model.md:1', 'release policy'],
}, null, 2)}\n`);
execFileSync('git', ['-C', context.root, 'add', 'domain-change.json']);
execFileSync('git', ['-C', context.root, 'commit', '-m', 'add domain proposal input']);
const run = (args) => spawnSync(process.execPath, [CLI, ...args], {
cwd: context.root, env: context.env, encoding: 'utf8',
});
const assessment = run(['fleet', 'assess', '--from', 'session-manager', '--json']);
assert.equal(assessment.status, 0, assessment.stderr || assessment.stdout);
assert.equal(JSON.parse(assessment.stdout).recommendation.action, 'retain-current-fleet');
const proposedResult = run(['fleet', 'propose', '--from', 'session-manager', '--proposal', inputPath, '--json']);
assert.equal(proposedResult.status, 0, proposedResult.stderr || proposedResult.stdout);
const proposed = JSON.parse(proposedResult.stdout);
const refused = run(['fleet', 'approve', '--change', proposed.id, '--by', 'fixture-owner', '--json']);
assert.equal(refused.status, 2);
assert.equal(JSON.parse(refused.stdout).error, 'APPROVAL_REQUIRED');
assert.equal(run(['fleet', 'approve', '--change', proposed.id, '--by', 'fixture-owner', '--yes', '--json']).status, 0);
const plan = run(['fleet', 'plan', '--change', proposed.id, '--json']);
assert.equal(plan.status, 0, plan.stderr || plan.stdout);
assert.equal(JSON.parse(plan.stdout).canProceed, true);
const activated = run(['fleet', 'activate', '--change', proposed.id, '--by', 'fixture-owner', '--yes', '--json']);
assert.equal(activated.status, 0, activated.stderr || activated.stdout);
assert.equal(JSON.parse(activated.stdout).identity.areaId, 'security');
const startPlan = run(['fleet', 'start', '--change', proposed.id, '--fresh', '--dry-run', '--json']);
assert.equal(startPlan.status, 0, startPlan.stderr || startPlan.stdout);
assert.deepEqual(JSON.parse(startPlan.stdout).actions.map((action) => action.areaId), ['security']);
assert.equal(run(['agent', '--area', 'security', '--json']).status, 0);
});
test('SCN-fleet-retirement: owner-approved retirement shrinks the roster without deleting branch history', () => {
const context = fixture();
const control = openControlPlane({ repositoryRoot: context.root, env: context.env });
let sequence = 0;
const evolution = new FleetEvolutionService({
repositoryRoot: context.root, controlPlane: control, idFactory: () => `CHANGE-${++sequence}`,
});
const existing = control.listAgents().find((agent) => agent.areaId !== 'session-manager').areaId;
const addition = evolution.proposeDomain({
proposer: 'session-manager',
domain: {
id: 'review', title: 'Review', scope: ['cross-boundary review'], not_scope: ['feature implementation'],
owned_paths: ['review/**'], shared_paths: [], neighbours: [existing], required_checks: [], resources: [], runtime: 'claude',
},
rationale: 'Milestone reviews recur without a coherent owner.',
expectedBenefit: {
summary: 'Keep a recurring review boundary focused.', recurringWork: 'Review work recurs each milestone.',
contextLocality: 'Retain the review assumptions in one session.', coordinationCost: 'One explicit handoff.',
},
evidence: ['TASK-9'],
});
evolution.approve({ changeId: addition.id, approvedBy: 'fixture-owner' });
const activated = evolution.activate({ changeId: addition.id, approvedBy: 'fixture-owner' });
assert.equal(existsSync(activated.worktree), true);
const retirement = callTorchTool(control, 'torch_propose_domain_retirement', {
area_id: 'review',
rationale: 'The review stream ended and a permanent session now costs more coordination than it saves.',
expected_benefit: {
summary: 'Shrink routine coordination while preserving review history.',
recurring_work: 'No recurring review work remains after the milestone.',
context_locality: 'Future isolated reviews can use bounded temporary help.',
coordination_cost: 'Removes an idle persistent handoff boundary.',
},
evidence: ['TASK-9 completed', 'decision: review milestone closed'],
}, { actorId: 'session-manager', evolutionService: evolution });
assert.equal(retirement.type, 'retire-domain');
evolution.approve({ changeId: retirement.id, approvedBy: 'fixture-owner' });
control.reportStatus({ areaId: 'review', state: 'idle', summary: 'Awaiting retirement.' });
assert.equal(evolution.planActivation(retirement.id).blockers
.some((blocker) => blocker.type === 'runtime-not-offline'), true);
control.reportStatus({ areaId: 'review', state: 'offline', summary: 'Stopped.' });
writeFileSync(join(activated.worktree, 'dirty.txt'), 'preserve me\n');
assert.equal(evolution.planActivation(retirement.id).blockers
.some((blocker) => blocker.type === 'worktree-unsafe' && blocker.problem === 'worktree-dirty'), true);
execFileSync('git', ['-C', activated.worktree, 'clean', '-f', '--', 'dirty.txt']);
const plan = evolution.planActivation(retirement.id);
assert.equal(plan.canProceed, true, JSON.stringify(plan.blockers));
const retired = evolution.activate({ changeId: retirement.id, approvedBy: 'fixture-owner' });
assert.equal(retired.state, 'retired');
assert.equal(existsSync(activated.worktree), false);
assert.notEqual(execFileSync('git', ['-C', context.root, 'branch', '--list', activated.branch], { encoding: 'utf8' }).trim(), '');
assert.equal(control.listAgents().some((agent) => agent.areaId === 'review'), false);
const config = JSON.parse(readFileSync(join(context.root, '.torch', 'torch.yaml'), 'utf8'));
assert.equal(config.domains.some((domain) => domain.id === 'review'), false);
assert.equal(config.retired_domains.some((domain) => domain.id === 'review' && domain.branch === activated.branch), true);
assert.match(execFileSync('git', ['-C', context.root, 'log', '-1', '--pretty=%s'], { encoding: 'utf8' }), /retire review domain/);
control.close();
});