mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-06 21:01:54 +00:00
Add guarded fleet boundary proposals
This commit is contained in:
1 parent
175d341e60
commit
3e988a295c
9 files changed
+475
-5
No files matched your search
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user