Add guarded fleet boundary proposals

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 18:22:18 -04:00
1 parent 175d341e60
commit 3e988a295c
9 files changed
+475 -5

No files matched your search

+4 -1
View File
@@ -66,7 +66,10 @@ and the first Claude reference-fleet path:
persistent domain, while owner approval gates the configuration commit,
identity, prompt, branch, and worktree activation; it can also propose safe
retirement when coordination cost exceeds continuing value, preserving the
branch and refusing live, dirty, guarded, leased, or active work.
branch and refusing live, dirty, guarded, leased, or active work. Evidence-
backed merge and split proposals are durable and owner-gated, but approval
deliberately cannot rewrite ownership: TORCH reports the separate migration
proof still required for backlog, branches, prompts, worktrees, and neighbours.
Live mixed-provider qualification, stable self-host bootstrapping, COMBATRIG
cutover, installation of the implemented system-schedule launcher on this
@@ -170,6 +170,10 @@ test_integrity_note:
given: a persistent specialist whose recurring work no longer justifies its coordination cost
when: the Session Manager proposes retirement and the owner approves it
then: live, dirty, guarded, leased, uniquely committed, or actively assigned work blocks retirement; safe activation removes the worktree and identity while preserving its branch and durable decision
- id: SCN-fleet-boundary-evolution
given: recurring work suggests an existing pair of domains should merge or one domain should split
when: the Session Manager submits complete ownership assignments and the owner reviews the durable proposal
then: invalid or stale boundaries are refused and approval cannot silently mutate identities, ownership, prompts, branches, or worktrees
- id: SCN-package-distribution
given: a clean TORCH source checkout and an isolated package consumer
when: npm builds and installs the published artifact without registry access
+30
View File
@@ -109,7 +109,10 @@ Usage:
torch fleet get --change <id> [--json]
torch fleet propose --from session-manager --proposal <path> [--json]
torch fleet propose-retirement --from session-manager --proposal <path> [--json]
torch fleet propose-merge --from session-manager --proposal <path> [--json]
torch fleet propose-split --from session-manager --proposal <path> [--json]
torch fleet approve --change <id> --by <owner> --yes [--json]
torch fleet reject --change <id> --by <owner> --reason <text> --yes [--json]
torch fleet plan --change <id> [--json]
torch fleet activate --change <id> --by <owner> --yes [--json]
torch fleet start --change <id> [--fresh] [--dry-run] --yes [--json]
@@ -200,6 +203,7 @@ function runCandidateAcceptance(candidateRoot, spawn) {
['ai-fleet-bootstrap', ['SCN-ai-fleet-bootstrap']],
['ai-fleet-planning', ['SCN-ai-fleet-planning']],
['fleet-evolution', ['SCN-fleet-evolution', 'SCN-cli-fleet-evolution']],
['fleet-boundary-evolution', ['SCN-fleet-boundary-evolution']],
['review-install-roster', ['SCN-cli-domain-review']],
['branches-worktrees', ['SCN-worktree-bootstrap']],
['runtime-identities', ['SCN-mixed-runtime']],
@@ -637,11 +641,37 @@ export async function runCli(argv = process.argv.slice(2), {
...input, areaId: input.area_id ?? input.areaId,
proposer: optionValue(argv, '--from') ?? input.proposer,
}), { json });
} else if (operation === 'propose-merge' || operation === 'propose-split') {
const input = loadJsonFile(cwd, optionValue(argv, '--proposal'), {
label: 'Fleet boundary proposal', code: 'FLEET_PROPOSAL_INVALID',
});
const request = {
...input,
proposer: optionValue(argv, '--from') ?? input.proposer,
sourceDomains: input.sourceDomains ?? input.source_domains,
resultDomains: input.resultDomains ?? input.result_domains,
ownershipAssignments: input.ownershipAssignments ?? input.ownership_assignments,
expectedBenefit: input.expectedBenefit ?? (input.expected_benefit ? {
summary: input.expected_benefit.summary,
recurringWork: input.expected_benefit.recurring_work,
contextLocality: input.expected_benefit.context_locality,
coordinationCost: input.expected_benefit.coordination_cost,
} : undefined),
};
print(operation === 'propose-merge'
? evolution.proposeMerge(request) : evolution.proposeSplit(request), { json });
} else if (operation === 'approve') {
if (!argv.includes('--yes')) {
throw new TorchError('Owner approval of a Fleet change requires explicit --yes.', { code: 'APPROVAL_REQUIRED' });
}
print(evolution.approve({ changeId, approvedBy: optionValue(argv, '--by') }), { json });
} else if (operation === 'reject') {
if (!argv.includes('--yes')) {
throw new TorchError('Owner rejection of a Fleet change requires explicit --yes.', { code: 'APPROVAL_REQUIRED' });
}
print(evolution.reject({
changeId, rejectedBy: optionValue(argv, '--by'), reason: optionValue(argv, '--reason'),
}), { json });
} else if (operation === 'plan') print(evolution.planActivation(changeId), { json });
else if (operation === 'activate') {
if (!argv.includes('--yes')) {
+246 -1
View File
@@ -115,7 +115,10 @@ function rowToChange(row) {
id: row.id, type: row.change_type ?? 'add-domain', state: row.state, proposer: row.proposer,
createdAt: row.created_at, updatedAt: row.updated_at, baseCommit: row.base_commit,
approvedBy: row.approved_by, approvedAt: row.approved_at,
rejectedBy: row.rejected_by ?? null, rejectedAt: row.rejected_at ?? null,
rejectionReason: row.rejection_reason ?? null,
activationCommit: row.activation_commit, domain: JSON.parse(row.domain_json),
proposal: row.proposal_json ? JSON.parse(row.proposal_json) : null,
rationale: row.rationale, expectedBenefit: JSON.parse(row.expected_benefit_json),
evidence: JSON.parse(row.evidence_json),
};
@@ -148,11 +151,15 @@ function initialize(database) {
base_commit TEXT NOT NULL,
approved_by TEXT,
approved_at TEXT,
rejected_by TEXT,
rejected_at TEXT,
rejection_reason TEXT,
activation_commit TEXT,
domain_json TEXT NOT NULL,
rationale TEXT NOT NULL,
expected_benefit_json TEXT NOT NULL,
evidence_json TEXT NOT NULL
evidence_json TEXT NOT NULL,
proposal_json TEXT
);
CREATE INDEX IF NOT EXISTS fleet_changes_state_created
ON fleet_changes(state, created_at, id);
@@ -161,6 +168,12 @@ function initialize(database) {
if (!columns.has('change_type')) {
database.exec("ALTER TABLE fleet_changes ADD COLUMN change_type TEXT NOT NULL DEFAULT 'add-domain'");
}
if (!columns.has('proposal_json')) {
database.exec('ALTER TABLE fleet_changes ADD COLUMN proposal_json TEXT');
}
if (!columns.has('rejected_by')) database.exec('ALTER TABLE fleet_changes ADD COLUMN rejected_by TEXT');
if (!columns.has('rejected_at')) database.exec('ALTER TABLE fleet_changes ADD COLUMN rejected_at TEXT');
if (!columns.has('rejection_reason')) database.exec('ALTER TABLE fleet_changes ADD COLUMN rejection_reason TEXT');
}
function expectedBenefit(input) {
@@ -176,6 +189,22 @@ function hasTable(database, name) {
return Boolean(database.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
}
function configDigest(repositoryRoot) {
return sha256(readFileSync(join(repositoryRoot, '.torch', 'torch.yaml')));
}
function normalizeAssignment(input) {
return {
sourceDomainId: requiredText(input?.sourceDomainId ?? input?.source_domain_id, 'ownershipAssignments.sourceDomainId'),
sourcePath: requiredText(input?.sourcePath ?? input?.source_path, 'ownershipAssignments.sourcePath'),
resultDomainId: requiredText(input?.resultDomainId ?? input?.result_domain_id, 'ownershipAssignments.resultDomainId'),
};
}
function stableDomain(domain) {
return JSON.stringify(domain);
}
export class FleetEvolutionService {
constructor({ repositoryRoot, controlPlane, clock = () => new Date(), idFactory = randomUUID } = {}) {
if (!controlPlane) throw new TorchError('Fleet evolution requires the TORCH control plane', { code: 'CONTROL_PLANE_REQUIRED' });
@@ -310,6 +339,164 @@ export class FleetEvolutionService {
return record;
}
proposeMerge(input = {}) {
return this.proposeBoundaryChange('merge-domains', input);
}
proposeSplit(input = {}) {
return this.proposeBoundaryChange('split-domain', input);
}
proposeBoundaryChange(changeType, {
proposer, sourceDomains: sourceInput, resultDomains: resultInput,
ownershipAssignments: assignmentInput, rationale, expectedBenefit: benefitInput, evidence,
} = {}) {
const actor = this.controlPlane.assertIdentity(proposer);
if (actor !== 'session-manager') {
throw new TorchError('Only the Session Manager may propose changing persistent Fleet boundaries', {
code: 'FLEET_CHANGE_AUTHORITY_REQUIRED', details: { actor },
});
}
if (!['merge-domains', 'split-domain'].includes(changeType)) {
throw new TorchError(`Unsupported Fleet boundary change: ${changeType}`, { code: 'FLEET_CHANGE_INVALID' });
}
const sourceIds = textList(sourceInput, 'sourceDomains', { required: true });
if ((changeType === 'merge-domains' && sourceIds.length < 2)
|| (changeType === 'split-domain' && sourceIds.length !== 1)) {
throw new TorchError(`${changeType} has invalid source-domain cardinality`, {
code: 'FLEET_CHANGE_INVALID', details: { sourceDomains: sourceIds },
});
}
if (sourceIds.includes('session-manager')) {
throw new TorchError('The primary Session Manager is not a merge or split source', { code: 'FLEET_CHANGE_INVALID' });
}
const config = loadProjectConfig(this.repositoryRoot);
const configuredById = new Map(config.domains.map((domain) => [domain.id, domain]));
const unknownSources = sourceIds.filter((id) => !configuredById.has(id));
if (unknownSources.length) {
throw new TorchError('Fleet boundary proposal references unknown source domains', {
code: 'FLEET_CHANGE_INVALID', details: { sourceDomains: unknownSources },
});
}
if (!Array.isArray(resultInput)) {
throw new TorchError('resultDomains must be an array', {
code: 'INVALID_FLEET_CHANGE', details: { field: 'resultDomains' },
});
}
const resultDomains = resultInput.map(normalizeDomain);
if ((changeType === 'merge-domains' && resultDomains.length !== 1)
|| (changeType === 'split-domain' && resultDomains.length < 2)) {
throw new TorchError(`${changeType} has invalid result-domain cardinality`, {
code: 'FLEET_CHANGE_INVALID', details: { resultDomains: resultDomains.map((domain) => domain.id) },
});
}
const resultIds = resultDomains.map((domain) => domain.id);
if (new Set(resultIds).size !== resultIds.length) {
throw new TorchError('Fleet boundary proposal has duplicate result-domain IDs', { code: 'FLEET_CHANGE_INVALID' });
}
const collisions = resultIds.filter((id) => configuredById.has(id) && !sourceIds.includes(id));
if (collisions.length) {
throw new TorchError('Result-domain IDs collide with unaffected Fleet identities', {
code: 'FLEET_IDENTITY_EXISTS', details: { domains: collisions },
});
}
const knownFutureIds = new Set([
'session-manager', ...config.domains.filter((domain) => !sourceIds.includes(domain.id)).map((domain) => domain.id),
...resultIds,
]);
const knownRuntimes = new Set(Object.keys(config.runtimes ?? {}));
const knownChecks = new Set((config.checks ?? []).map((check) => check.id));
const knownResources = new Set((config.resources ?? []).map((resource) => resource.id));
for (const domain of resultDomains) {
const invalid = {
neighbours: domain.neighbours.filter((id) => !knownFutureIds.has(id)),
checks: domain.required_checks.filter((id) => !knownChecks.has(id)),
resources: domain.resources.filter((id) => !knownResources.has(id)),
};
if (!knownRuntimes.has(domain.runtime) || Object.values(invalid).some((items) => items.length)) {
throw new TorchError('Result domain references Fleet configuration that will not exist', {
code: 'FLEET_CHANGE_INVALID', details: { domainId: domain.id, runtime: domain.runtime, ...invalid },
});
}
}
const assignments = Array.isArray(assignmentInput) ? assignmentInput.map(normalizeAssignment) : [];
const expectedOwnership = sourceIds.flatMap((id) => configuredById.get(id).owned_paths
.map((path) => `${id}\0${path}`));
const assignedOwnership = assignments.map((assignment) => `${assignment.sourceDomainId}\0${assignment.sourcePath}`);
if (assignments.some((assignment) => !sourceIds.includes(assignment.sourceDomainId)
|| !resultIds.includes(assignment.resultDomainId))
|| new Set(assignedOwnership).size !== assignedOwnership.length
|| expectedOwnership.length !== assignedOwnership.length
|| expectedOwnership.some((entry) => !assignedOwnership.includes(entry))) {
throw new TorchError('Every source ownership pattern must be assigned exactly once to a result domain', {
code: 'FLEET_OWNERSHIP_ASSIGNMENT_INVALID',
details: { expected: expectedOwnership, assigned: assignedOwnership },
});
}
const resultOwnership = resultDomains.flatMap((domain) => domain.owned_paths
.map((path) => ({ domainId: domain.id, path })));
const assignedResults = assignments.map((assignment) => `${assignment.resultDomainId}\0${assignment.sourcePath}`);
const declaredResults = resultOwnership.map((entry) => `${entry.domainId}\0${entry.path}`);
if (new Set(declaredResults).size !== declaredResults.length
|| assignedResults.length !== declaredResults.length
|| assignedResults.some((entry) => !declaredResults.includes(entry))) {
throw new TorchError('Result owned paths must exactly match the explicit ownership assignments', {
code: 'FLEET_OWNERSHIP_ASSIGNMENT_INVALID', details: { assigned: assignedResults, declared: declaredResults },
});
}
for (let left = 0; left < resultOwnership.length; left += 1) {
for (let right = left + 1; right < resultOwnership.length; right += 1) {
if (resultOwnership[left].domainId !== resultOwnership[right].domainId
&& pathsOverlap(resultOwnership[left].path, resultOwnership[right].path)) {
throw new TorchError('Result domains have overlapping ownership', {
code: 'FLEET_OWNERSHIP_COLLISION', details: [resultOwnership[left], resultOwnership[right]],
});
}
}
}
const unaffected = config.domains.filter((domain) => !sourceIds.includes(domain.id));
const externalCollisions = resultOwnership.flatMap((result) => unaffected.flatMap((domain) =>
domain.owned_paths.filter((path) => pathsOverlap(result.path, path))
.map((path) => ({ resultDomainId: result.domainId, resultPath: result.path, areaId: domain.id, existingPath: path }))));
if (externalCollisions.length) {
throw new TorchError('Result ownership overlaps an unaffected Fleet domain', {
code: 'FLEET_OWNERSHIP_COLLISION', details: externalCollisions,
});
}
const now = this.clock().toISOString();
const proposal = {
schema: 'torch.dev/fleet-boundary-proposal/v1alpha1',
operation: changeType === 'merge-domains' ? 'merge' : 'split',
configDigest: configDigest(this.repositoryRoot),
sourceDomains: sourceIds.map((id) => configuredById.get(id)),
resultDomains, ownershipAssignments: assignments,
};
const record = {
id: this.idFactory(), type: changeType, state: 'proposed', proposer: actor,
createdAt: now, updatedAt: now, baseCommit: git(this.repositoryRoot, ['rev-parse', 'HEAD']),
approvedBy: null, approvedAt: null, activationCommit: null, domain: null, proposal,
rationale: requiredText(rationale, 'rationale'), expectedBenefit: expectedBenefit(benefitInput),
evidence: textList(evidence, 'evidence', { required: true }),
};
this.database.prepare(`
INSERT INTO fleet_changes (
id, change_type, state, proposer, created_at, updated_at, base_commit, approved_by,
approved_at, activation_commit, domain_json, rationale, expected_benefit_json, evidence_json,
proposal_json
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, 'null', ?, ?, ?, ?)
`).run(
record.id, record.type, record.state, record.proposer, record.createdAt, record.updatedAt,
record.baseCommit, record.rationale, JSON.stringify(record.expectedBenefit),
JSON.stringify(record.evidence), JSON.stringify(proposal),
);
this.controlPlane.audit({
actorId: actor, operation: `fleet-change.propose-${proposal.operation}`,
entityType: 'fleet-change', entityId: record.id,
details: { sourceDomains: sourceIds, resultDomains: resultIds, baseCommit: record.baseCommit },
});
return record;
}
approve({ changeId, approvedBy } = {}) {
const change = this.get(changeId);
if (change.state !== 'proposed') {
@@ -328,9 +515,32 @@ export class FleetEvolutionService {
return this.get(change.id);
}
reject({ changeId, rejectedBy, reason } = {}) {
const change = this.get(changeId);
if (change.state !== 'proposed') {
throw new TorchError(`Fleet change ${change.id} is ${change.state}, not proposed`, { code: 'FLEET_CHANGE_STATE_CONFLICT' });
}
const owner = requiredText(rejectedBy, 'rejectedBy');
const explanation = requiredText(reason, 'reason');
const now = this.clock().toISOString();
this.database.prepare(`
UPDATE fleet_changes SET state = 'rejected', rejected_by = ?, rejected_at = ?,
rejection_reason = ?, updated_at = ?
WHERE id = ? AND state = 'proposed'
`).run(owner, now, explanation, now, change.id);
this.controlPlane.audit({
actorId: 'session-manager', operation: 'fleet-change.owner-rejected', entityType: 'fleet-change', entityId: change.id,
details: { rejectedBy: owner, reason: explanation },
});
return this.get(change.id);
}
planActivation(changeId) {
const change = this.get(changeId);
if (change.type === 'retire-domain') return this.planRetirement(change);
if (change.type === 'merge-domains' || change.type === 'split-domain') {
return this.planBoundaryChange(change);
}
const config = loadProjectConfig(this.repositoryRoot);
const roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'ROSTER_INVALID');
const currentHead = git(this.repositoryRoot, ['rev-parse', 'HEAD']);
@@ -375,6 +585,11 @@ export class FleetEvolutionService {
});
}
if (change.type === 'retire-domain') return this.retire(change, approvedBy);
if (change.type === 'merge-domains' || change.type === 'split-domain') {
throw new TorchError('Merge and split approval does not authorize automatic ownership migration', {
code: 'FLEET_BOUNDARY_MIGRATION_REQUIRED', details: this.planBoundaryChange(change),
});
}
const plan = this.planActivation(change.id);
if (!plan.canProceed) {
throw new TorchError('Fleet domain activation is blocked', {
@@ -411,6 +626,36 @@ export class FleetEvolutionService {
};
}
planBoundaryChange(changeOrId) {
const change = typeof changeOrId === 'string' ? this.get(changeOrId) : changeOrId;
const currentHead = git(this.repositoryRoot, ['rev-parse', 'HEAD']);
const config = loadProjectConfig(this.repositoryRoot);
const configuredById = new Map(config.domains.map((domain) => [domain.id, domain]));
const proposal = change.proposal;
const blockers = [];
if (change.state !== 'approved') blockers.push({ type: 'state', actual: change.state, required: 'approved' });
if (currentHead !== change.baseCommit) blockers.push({ type: 'stale-head', expected: change.baseCommit, actual: currentHead });
const currentDigest = configDigest(this.repositoryRoot);
if (currentDigest !== proposal.configDigest) {
blockers.push({ type: 'configuration-changed', expected: proposal.configDigest, actual: currentDigest });
}
for (const source of proposal.sourceDomains) {
const current = configuredById.get(source.id);
if (!current) blockers.push({ type: 'source-domain-missing', domainId: source.id });
else if (stableDomain(current) !== stableDomain(source)) blockers.push({ type: 'source-domain-changed', domainId: source.id });
}
blockers.push({
type: 'manual-boundary-redesign-required',
reason: 'A separate migration must prove backlog, branch, prompt, worktree, neighbour, and ownership transfer safety.',
});
return {
action: change.type, changeId: change.id, state: change.state,
sourceDomains: proposal.sourceDomains.map((domain) => domain.id),
resultDomains: proposal.resultDomains.map((domain) => domain.id),
currentHead, baseCommit: change.baseCommit, blockers, canProceed: false, mutationPerformed: false,
};
}
planRetirement(changeOrId) {
const change = typeof changeOrId === 'string' ? this.get(changeOrId) : changeOrId;
const config = loadProjectConfig(this.repositoryRoot);
+45
View File
@@ -57,6 +57,8 @@ export const TORCH_MCP_TOOL_NAMES = Object.freeze([
'torch_get_fleet_change',
'torch_propose_domain',
'torch_propose_domain_retirement',
'torch_propose_domain_merge',
'torch_propose_domain_split',
]);
function claimedIdentity(controlPlane, actorId, claim, field) {
@@ -456,6 +458,49 @@ export function createTorchToolset(controlPlane, {
},
}),
});
const boundaryProposalSchema = {
proposer: z.string().min(1).optional(),
source_domains: z.array(z.string().min(1)).min(1),
result_domains: z.array(z.object({
id: z.string().min(1), title: z.string().min(1), kind: z.string().min(1).optional(),
scope: z.array(z.string().min(1)).min(1), not_scope: z.array(z.string().min(1)).optional(),
owned_paths: z.array(z.string().min(1)).min(1), shared_paths: z.array(z.string().min(1)).optional(),
neighbours: z.array(z.string().min(1)).optional(), required_checks: z.array(z.string().min(1)).optional(),
resources: z.array(z.string().min(1)).optional(), runtime: z.string().min(1).optional(),
model: z.string().min(1).optional(), branch: z.string().min(1).optional(),
worktree_name: z.string().min(1).optional(),
})).min(1),
ownership_assignments: z.array(z.object({
source_domain_id: z.string().min(1), source_path: z.string().min(1), result_domain_id: z.string().min(1),
})).min(1),
rationale: z.string().min(1),
expected_benefit: z.object({
summary: z.string().min(1), recurring_work: z.string().min(1),
context_locality: z.string().min(1), coordination_cost: z.string().min(1),
}),
evidence: z.array(z.string().min(1)).min(1),
};
const invokeBoundaryProposal = (operation) => ({
proposer, source_domains: sourceDomains, result_domains: resultDomains,
ownership_assignments: ownershipAssignments, expected_benefit: benefit, ...input
}) => evolutionService[operation]({
...input, sourceDomains, resultDomains, ownershipAssignments,
proposer: claimedIdentity(controlPlane, actorId, proposer, 'proposer'),
expectedBenefit: {
summary: benefit.summary, recurringWork: benefit.recurring_work,
contextLocality: benefit.context_locality, coordinationCost: benefit.coordination_cost,
},
});
tools.set('torch_propose_domain_merge', {
description: 'Propose an evidence-backed merge of persistent domains. Approval records intent but cannot automatically migrate ownership.',
schema: boundaryProposalSchema,
invoke: invokeBoundaryProposal('proposeMerge'),
});
tools.set('torch_propose_domain_split', {
description: 'Propose an evidence-backed split of a persistent domain. Approval records intent but cannot automatically migrate ownership.',
schema: boundaryProposalSchema,
invoke: invokeBoundaryProposal('proposeSplit'),
});
}
return tools;
}
+3 -2
View File
@@ -138,8 +138,9 @@ export function observeProject({ repositoryRoot, env = process.env, now = () =>
FROM integration_requests ORDER BY updated_at DESC LIMIT 40
`);
result.fleetChanges = rows(database, 'fleet_changes', `
SELECT id, state, proposer, json_extract(domain_json, '$.id') AS domainId,
json_extract(domain_json, '$.title') AS title,
SELECT id, change_type AS changeType, state, proposer,
COALESCE(json_extract(domain_json, '$.id'), json_extract(proposal_json, '$.resultDomains[0].id')) AS domainId,
COALESCE(json_extract(domain_json, '$.title'), json_extract(proposal_json, '$.resultDomains[0].title')) AS title,
json_extract(expected_benefit_json, '$.summary') AS expectedBenefit,
approved_by AS approvedBy, updated_at AS updatedAt
FROM fleet_changes ORDER BY updated_at DESC, id LIMIT 40
+1
View File
@@ -13,6 +13,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([
'ai-fleet-bootstrap',
'ai-fleet-planning',
'fleet-evolution',
'fleet-boundary-evolution',
'review-install-roster',
'branches-worktrees',
'runtime-identities',
+1
View File
@@ -36,6 +36,7 @@ function releaseCandidate(root, version) {
'SCN-ai-fleet-bootstrap',
'SCN-ai-fleet-planning',
'SCN-fleet-evolution', 'SCN-cli-fleet-evolution',
'SCN-fleet-boundary-evolution',
'SCN-durable-message', 'SCN-ownership-handoff', 'SCN-worktree-purge-safety',
'SCN-backlog-lifecycle', 'SCN-native-integration', 'SCN-resource-fifo',
'SCN-fleet-fresh-resume', 'SCN-install-doctor-purge',
+141 -1
View File
@@ -17,7 +17,7 @@ import { observeProject } from '../../src/observability/snapshot.mjs';
const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname;
function fixture() {
function fixture({ boundary = false } = {}) {
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-')) };
@@ -29,6 +29,19 @@ function fixture() {
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: [],
@@ -40,6 +53,133 @@ function fixture() {
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();
const control = openControlPlane({ repositoryRoot: context.root, env: context.env });