mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-31 18:01:55 +00:00
Add safe domain retirement lifecycle
This commit is contained in:
1 parent
104f5f4469
commit
acfbc6d32e
8 files changed
+297
-17
No files matched your search
@@ -62,7 +62,9 @@ and the first Claude reference-fleet path:
|
||||
retries, durable run evidence, and explicit approval for mutation.
|
||||
- durable Fleet evolution: the Session Manager can propose a justified new
|
||||
persistent domain, while owner approval gates the configuration commit,
|
||||
identity, prompt, branch, and worktree activation.
|
||||
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.
|
||||
|
||||
Live mixed-provider qualification, stable self-host bootstrapping, COMBATRIG
|
||||
cutover, persistent system-schedule launcher integration, and public deployment
|
||||
|
||||
@@ -162,6 +162,10 @@ test_integrity_note:
|
||||
given: an installed Fleet whose Session Manager identifies recurring work without a coherent owner
|
||||
when: it proposes a durable domain and the owner explicitly approves activation
|
||||
then: TORCH commits the roster change, provisions its branch and clean worktree, exposes the identity to existing and restarted control-plane clients, and can start only that identity through a virtualized adapter
|
||||
- id: SCN-fleet-retirement
|
||||
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-cli-fleet-evolution
|
||||
given: an installed Fleet and a file-backed proposed domain
|
||||
when: the manager proposes it and the owner separately approves, plans, and activates it through the public CLI
|
||||
|
||||
+11
-2
@@ -106,6 +106,7 @@ Usage:
|
||||
torch fleet changes [--state <state>] [--json]
|
||||
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 approve --change <id> --by <owner> --yes [--json]
|
||||
torch fleet plan --change <id> [--json]
|
||||
torch fleet activate --change <id> --by <owner> --yes [--json]
|
||||
@@ -626,6 +627,14 @@ export async function runCli(argv = process.argv.slice(2), {
|
||||
print(evolution.proposeDomain({
|
||||
...input, proposer: optionValue(argv, '--from') ?? input.proposer,
|
||||
}), { json });
|
||||
} else if (operation === 'propose-retirement') {
|
||||
const input = loadJsonFile(cwd, optionValue(argv, '--proposal'), {
|
||||
label: 'Fleet domain retirement proposal', code: 'FLEET_PROPOSAL_INVALID',
|
||||
});
|
||||
print(evolution.proposeRetirement({
|
||||
...input, areaId: input.area_id ?? input.areaId,
|
||||
proposer: optionValue(argv, '--from') ?? input.proposer,
|
||||
}), { json });
|
||||
} else if (operation === 'approve') {
|
||||
if (!argv.includes('--yes')) {
|
||||
throw new TorchError('Owner approval of a Fleet change requires explicit --yes.', { code: 'APPROVAL_REQUIRED' });
|
||||
@@ -634,14 +643,14 @@ export async function runCli(argv = process.argv.slice(2), {
|
||||
} else if (operation === 'plan') print(evolution.planActivation(changeId), { json });
|
||||
else if (operation === 'activate') {
|
||||
if (!argv.includes('--yes')) {
|
||||
throw new TorchError('Fleet activation commits configuration and creates a worktree. Review fleet plan, then use --yes.', {
|
||||
throw new TorchError('Fleet activation changes tracked organization state and may create or remove a worktree. Review fleet plan, then use --yes.', {
|
||||
code: 'APPROVAL_REQUIRED',
|
||||
});
|
||||
}
|
||||
print(evolution.activate({ changeId, approvedBy: optionValue(argv, '--by') }), { json });
|
||||
} else if (operation === 'start') {
|
||||
const change = evolution.get(changeId);
|
||||
if (change.state !== 'active') {
|
||||
if (change.type !== 'add-domain' || change.state !== 'active') {
|
||||
throw new TorchError('Only an active Fleet domain can start a runtime session', {
|
||||
code: 'FLEET_CHANGE_STATE_CONFLICT', details: { state: change.state },
|
||||
});
|
||||
|
||||
+188
-12
@@ -1,14 +1,15 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
import { readInstallManifest } from '../kernel/install.mjs';
|
||||
import { ensureTaskIgnored, removeTaskIgnore } from '../kernel/worktrees.mjs';
|
||||
import { loadProjectConfig } from '../kernel/config.mjs';
|
||||
import { inspectManagedWorktree } from '../kernel/worktree-state.mjs';
|
||||
|
||||
const CHANGE_STATES = Object.freeze(['proposed', 'approved', 'provisioning', 'active', 'rejected']);
|
||||
const CHANGE_STATES = Object.freeze(['proposed', 'approved', 'provisioning', 'active', 'retired', 'rejected']);
|
||||
|
||||
function requiredText(value, name) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
@@ -111,7 +112,7 @@ function normalizeDomain(input) {
|
||||
function rowToChange(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id, state: row.state, proposer: row.proposer,
|
||||
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,
|
||||
activationCommit: row.activation_commit, domain: JSON.parse(row.domain_json),
|
||||
@@ -139,6 +140,7 @@ function initialize(database) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS fleet_changes (
|
||||
id TEXT PRIMARY KEY,
|
||||
change_type TEXT NOT NULL DEFAULT 'add-domain',
|
||||
state TEXT NOT NULL,
|
||||
proposer TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -155,6 +157,23 @@ function initialize(database) {
|
||||
CREATE INDEX IF NOT EXISTS fleet_changes_state_created
|
||||
ON fleet_changes(state, created_at, id);
|
||||
`);
|
||||
const columns = new Set(database.prepare('PRAGMA table_info(fleet_changes)').all().map((column) => column.name));
|
||||
if (!columns.has('change_type')) {
|
||||
database.exec("ALTER TABLE fleet_changes ADD COLUMN change_type TEXT NOT NULL DEFAULT 'add-domain'");
|
||||
}
|
||||
}
|
||||
|
||||
function expectedBenefit(input) {
|
||||
return {
|
||||
summary: requiredText(input?.summary, 'expectedBenefit.summary'),
|
||||
recurringWork: requiredText(input?.recurringWork, 'expectedBenefit.recurringWork'),
|
||||
contextLocality: requiredText(input?.contextLocality, 'expectedBenefit.contextLocality'),
|
||||
coordinationCost: requiredText(input?.coordinationCost, 'expectedBenefit.coordinationCost'),
|
||||
};
|
||||
}
|
||||
|
||||
function hasTable(database, name) {
|
||||
return Boolean(database.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||
}
|
||||
|
||||
export class FleetEvolutionService {
|
||||
@@ -185,7 +204,7 @@ export class FleetEvolutionService {
|
||||
return rows.map(rowToChange);
|
||||
}
|
||||
|
||||
proposeDomain({ proposer, domain: input, rationale, expectedBenefit, evidence } = {}) {
|
||||
proposeDomain({ proposer, domain: input, rationale, expectedBenefit: benefitInput, evidence } = {}) {
|
||||
const actor = this.controlPlane.assertIdentity(proposer);
|
||||
if (actor !== 'session-manager') {
|
||||
throw new TorchError('Only the Session Manager may propose a persistent Fleet domain', {
|
||||
@@ -198,12 +217,7 @@ export class FleetEvolutionService {
|
||||
throw new TorchError(`Fleet identity already exists: ${domain.id}`, { code: 'FLEET_IDENTITY_EXISTS' });
|
||||
}
|
||||
for (const neighbour of domain.neighbours) this.controlPlane.assertIdentity(neighbour);
|
||||
const benefit = {
|
||||
summary: requiredText(expectedBenefit?.summary, 'expectedBenefit.summary'),
|
||||
recurringWork: requiredText(expectedBenefit?.recurringWork, 'expectedBenefit.recurringWork'),
|
||||
contextLocality: requiredText(expectedBenefit?.contextLocality, 'expectedBenefit.contextLocality'),
|
||||
coordinationCost: requiredText(expectedBenefit?.coordinationCost, 'expectedBenefit.coordinationCost'),
|
||||
};
|
||||
const benefit = expectedBenefit(benefitInput);
|
||||
const evidenceList = textList(evidence, 'evidence', { required: true });
|
||||
const collisions = agents.flatMap((agent) => domain.owned_paths.flatMap((proposedPath) =>
|
||||
agent.ownedPaths.filter((existingPath) => pathsOverlap(proposedPath, existingPath))
|
||||
@@ -242,10 +256,10 @@ export class FleetEvolutionService {
|
||||
};
|
||||
this.database.prepare(`
|
||||
INSERT INTO fleet_changes (
|
||||
id, state, proposer, created_at, updated_at, base_commit, approved_by,
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?)
|
||||
) VALUES (?, 'add-domain', ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?)
|
||||
`).run(
|
||||
record.id, record.state, record.proposer, record.createdAt, record.updatedAt,
|
||||
record.baseCommit, JSON.stringify(record.domain), record.rationale,
|
||||
@@ -258,6 +272,44 @@ export class FleetEvolutionService {
|
||||
return record;
|
||||
}
|
||||
|
||||
proposeRetirement({ proposer, areaId, rationale, expectedBenefit: benefitInput, evidence } = {}) {
|
||||
const actor = this.controlPlane.assertIdentity(proposer);
|
||||
if (actor !== 'session-manager') {
|
||||
throw new TorchError('Only the Session Manager may propose retiring a persistent Fleet domain', {
|
||||
code: 'FLEET_CHANGE_AUTHORITY_REQUIRED', details: { actor },
|
||||
});
|
||||
}
|
||||
const target = this.controlPlane.identity(areaId);
|
||||
if (target.areaId === 'session-manager') {
|
||||
throw new TorchError('The primary Session Manager cannot be retired as a domain', { code: 'FLEET_CHANGE_INVALID' });
|
||||
}
|
||||
const domain = loadProjectConfig(this.repositoryRoot).domains.find((entry) => entry.id === target.areaId);
|
||||
if (!domain) throw new TorchError(`Configured domain not found: ${target.areaId}`, { code: 'FLEET_CHANGE_INVALID' });
|
||||
const now = this.clock().toISOString();
|
||||
const record = {
|
||||
id: this.idFactory(), type: 'retire-domain', state: 'proposed', proposer: actor,
|
||||
createdAt: now, updatedAt: now, baseCommit: git(this.repositoryRoot, ['rev-parse', 'HEAD']),
|
||||
approvedBy: null, approvedAt: null, activationCommit: null, domain,
|
||||
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
|
||||
) VALUES (?, 'retire-domain', ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?)
|
||||
`).run(
|
||||
record.id, record.state, record.proposer, record.createdAt, record.updatedAt,
|
||||
record.baseCommit, JSON.stringify(record.domain), record.rationale,
|
||||
JSON.stringify(record.expectedBenefit), JSON.stringify(record.evidence),
|
||||
);
|
||||
this.controlPlane.audit({
|
||||
actorId: actor, operation: 'fleet-change.propose-retirement', entityType: 'fleet-change', entityId: record.id,
|
||||
details: { domainId: domain.id, baseCommit: record.baseCommit },
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
approve({ changeId, approvedBy } = {}) {
|
||||
const change = this.get(changeId);
|
||||
if (change.state !== 'proposed') {
|
||||
@@ -278,6 +330,7 @@ export class FleetEvolutionService {
|
||||
|
||||
planActivation(changeId) {
|
||||
const change = this.get(changeId);
|
||||
if (change.type === 'retire-domain') return this.planRetirement(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']);
|
||||
@@ -321,6 +374,7 @@ export class FleetEvolutionService {
|
||||
code: 'FLEET_CHANGE_AUTHORITY_REQUIRED', details: { approvedBy: change.approvedBy },
|
||||
});
|
||||
}
|
||||
if (change.type === 'retire-domain') return this.retire(change, approvedBy);
|
||||
const plan = this.planActivation(change.id);
|
||||
if (!plan.canProceed) {
|
||||
throw new TorchError('Fleet domain activation is blocked', {
|
||||
@@ -357,6 +411,128 @@ export class FleetEvolutionService {
|
||||
};
|
||||
}
|
||||
|
||||
planRetirement(changeOrId) {
|
||||
const change = typeof changeOrId === 'string' ? this.get(changeOrId) : changeOrId;
|
||||
const config = loadProjectConfig(this.repositoryRoot);
|
||||
const roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'ROSTER_INVALID');
|
||||
const manifest = readInstallManifest(this.repositoryRoot);
|
||||
const currentHead = git(this.repositoryRoot, ['rev-parse', 'HEAD']);
|
||||
const dirtyEntries = git(this.repositoryRoot, ['status', '--porcelain']).split('\n').filter(Boolean);
|
||||
const worktree = (manifest.external ?? []).find((entry) => entry.type === 'worktree' && entry.area === change.domain.id);
|
||||
const blockers = [];
|
||||
if (change.state !== 'approved' && change.state !== 'provisioning') blockers.push({ type: 'state', actual: change.state });
|
||||
if (change.state === 'approved' && currentHead !== change.baseCommit) blockers.push({ type: 'stale-head', expected: change.baseCommit, actual: currentHead });
|
||||
if (change.state === 'approved' && dirtyEntries.length) blockers.push({ type: 'dirty-main', entries: dirtyEntries });
|
||||
if (!config.domains.some((entry) => entry.id === change.domain.id)) blockers.push({ type: 'domain-not-configured', domainId: change.domain.id });
|
||||
if (config.domains.length <= 1) blockers.push({ type: 'last-development-domain' });
|
||||
if (!roster.areas.some((entry) => entry.id === change.domain.id)) blockers.push({ type: 'identity-not-rostered', domainId: change.domain.id });
|
||||
if (!worktree) blockers.push({ type: 'worktree-not-managed', domainId: change.domain.id });
|
||||
if (worktree) {
|
||||
const state = inspectManagedWorktree(this.repositoryRoot, worktree, config.project.main_branch);
|
||||
for (const problem of state.problems) blockers.push({ type: 'worktree-unsafe', problem, path: worktree.path });
|
||||
}
|
||||
const identity = this.controlPlane.identity(change.domain.id);
|
||||
if (identity.state !== 'offline') blockers.push({ type: 'runtime-not-offline', state: identity.state });
|
||||
if (hasTable(this.database, 'worktree_guards')) {
|
||||
const guards = this.database.prepare(`SELECT guard_type AS type, reason FROM worktree_guards
|
||||
WHERE area_id = ? AND released_at IS NULL`).all(change.domain.id);
|
||||
if (guards.length) blockers.push({ type: 'worktree-guards-active', guards });
|
||||
}
|
||||
if (hasTable(this.database, 'resource_leases')) {
|
||||
const leases = this.database.prepare(`SELECT resource_id AS resourceId FROM resource_leases
|
||||
WHERE area_id = ? AND released_at IS NULL`).all(change.domain.id);
|
||||
if (leases.length) blockers.push({ type: 'resource-leases-active', leases });
|
||||
}
|
||||
if (hasTable(this.database, 'integration_requests')) {
|
||||
const requests = this.database.prepare(`SELECT id, state FROM integration_requests
|
||||
WHERE source_area = ? AND state NOT IN ('landed', 'superseded')`).all(change.domain.id);
|
||||
if (requests.length) blockers.push({ type: 'integration-active', requests });
|
||||
}
|
||||
const manager = (manifest.external ?? []).find((entry) => entry.type === 'worktree' && entry.area === 'session-manager');
|
||||
const backlogRoot = join(manager?.path && existsSync(manager.path) ? manager.path : this.repositoryRoot, '.torch', 'backlog');
|
||||
if (existsSync(backlogRoot)) {
|
||||
const tasks = readdirSync(backlogRoot).filter((name) => name.endsWith('.json')).flatMap((name) => {
|
||||
try {
|
||||
const task = readJson(join(backlogRoot, name), 'BACKLOG_INVALID');
|
||||
const active = !['completed', 'cancelled'].includes(task.state)
|
||||
&& (task.owner === change.domain.id || task.affectedDomains?.includes(change.domain.id));
|
||||
return active ? [{ id: task.id, state: task.state }] : [];
|
||||
} catch { return [{ id: name, state: 'invalid' }]; }
|
||||
});
|
||||
if (tasks.length) blockers.push({ type: 'backlog-active', tasks });
|
||||
}
|
||||
return {
|
||||
action: 'retire-domain', changeId: change.id, domainId: change.domain.id,
|
||||
state: change.state, branch: worktree?.branch ?? change.domain.branch,
|
||||
worktree: worktree?.path ?? null, currentHead, baseCommit: change.baseCommit,
|
||||
blockers, canProceed: blockers.length === 0, mutationPerformed: false,
|
||||
};
|
||||
}
|
||||
|
||||
retire(change, approvedBy) {
|
||||
const plan = this.planRetirement(change);
|
||||
if (!plan.canProceed) throw new TorchError('Fleet domain retirement is blocked', { code: 'FLEET_CHANGE_BLOCKED', details: plan.blockers });
|
||||
const owner = requiredText(approvedBy, 'approvedBy');
|
||||
git(this.repositoryRoot, ['worktree', 'remove', plan.worktree]);
|
||||
try {
|
||||
this.commitRetirement(change, plan, owner);
|
||||
} catch (error) {
|
||||
git(this.repositoryRoot, ['worktree', 'add', plan.worktree, plan.branch], { optional: true });
|
||||
throw error;
|
||||
}
|
||||
this.controlPlane.refreshRoster();
|
||||
this.controlPlane.audit({
|
||||
actorId: 'session-manager', operation: 'fleet-change.retire-domain', entityType: 'fleet-change', entityId: change.id,
|
||||
details: { domainId: change.domain.id, branch: plan.branch },
|
||||
});
|
||||
return { ...this.get(change.id), branch: plan.branch, worktree: plan.worktree, mutationPerformed: true };
|
||||
}
|
||||
|
||||
commitRetirement(change, plan, approvedBy) {
|
||||
const config = loadProjectConfig(this.repositoryRoot);
|
||||
const rosterPath = join(this.repositoryRoot, '.torch', 'roster.yaml');
|
||||
const roster = readJson(rosterPath, 'ROSTER_INVALID');
|
||||
const manifest = readInstallManifest(this.repositoryRoot);
|
||||
config.domains = config.domains.filter((entry) => entry.id !== change.domain.id);
|
||||
config.retired_domains ??= [];
|
||||
config.retired_domains.push({
|
||||
id: change.domain.id, title: change.domain.title, branch: plan.branch,
|
||||
change_id: change.id, retired_at: this.clock().toISOString(),
|
||||
});
|
||||
roster.areas = roster.areas.filter((entry) => entry.id !== change.domain.id)
|
||||
.map((entry) => ({ ...entry, neighbours: (entry.neighbours ?? []).filter((id) => id !== change.domain.id) }));
|
||||
manifest.external = (manifest.external ?? []).filter((entry) => !(entry.type === 'worktree' && entry.area === change.domain.id));
|
||||
const changeRelative = `.torch/fleet-changes/${change.id}.retired.json`;
|
||||
const record = {
|
||||
schema: 'torch.dev/fleet-change/v1alpha1', ...change, state: 'retired', approvedBy,
|
||||
retirement: { branch: plan.branch, worktreeRemoved: plan.worktree, branchPreserved: true },
|
||||
};
|
||||
const serialized = new Map([
|
||||
['.torch/torch.yaml', `${JSON.stringify(config, null, 2)}\n`],
|
||||
['.torch/roster.yaml', `${JSON.stringify(roster, null, 2)}\n`],
|
||||
[changeRelative, `${JSON.stringify(record, null, 2)}\n`],
|
||||
]);
|
||||
for (const [relativePath, content] of serialized) {
|
||||
const existing = manifest.created.find((entry) => entry.path === relativePath);
|
||||
if (existing) existing.sha256 = sha256(content);
|
||||
else manifest.created.push({ path: relativePath, sha256: sha256(content) });
|
||||
}
|
||||
serialized.set('.torch/install-manifest.json', `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
for (const [relativePath, content] of serialized) {
|
||||
const path = join(this.repositoryRoot, relativePath);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
||||
writeFileSync(temporary, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
renameSync(temporary, path);
|
||||
}
|
||||
git(this.repositoryRoot, ['add', ...serialized.keys()]);
|
||||
git(this.repositoryRoot, ['commit', '-m', `chore(torch): retire ${change.domain.id} domain`]);
|
||||
const activationCommit = git(this.repositoryRoot, ['rev-parse', 'HEAD']);
|
||||
const now = this.clock().toISOString();
|
||||
this.database.prepare(`UPDATE fleet_changes SET state = 'retired', activation_commit = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(activationCommit, now, change.id);
|
||||
}
|
||||
|
||||
commitConfiguration(change, plan, ignore) {
|
||||
const rosterPath = join(this.repositoryRoot, '.torch', 'roster.yaml');
|
||||
const config = loadProjectConfig(this.repositoryRoot);
|
||||
|
||||
@@ -106,6 +106,13 @@ export const projectConfigSchema = z.object({
|
||||
worktree_name: text.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/).nullable().optional(),
|
||||
}).strict(),
|
||||
domains: z.array(domain).min(1),
|
||||
retired_domains: z.array(z.object({
|
||||
id,
|
||||
title: text,
|
||||
branch: text,
|
||||
change_id: text,
|
||||
retired_at: text,
|
||||
}).strict()).optional(),
|
||||
}).strict().superRefine((config, context) => {
|
||||
const runtimeNames = new Set(Object.keys(config.runtimes).filter((name) => name !== 'default'));
|
||||
if (!runtimeNames.has(config.runtimes.default)) {
|
||||
|
||||
@@ -90,7 +90,7 @@ function initialFiles({ repository, projectId, createdAt, proposal }) {
|
||||
const managerPrompt = `${promptContent(
|
||||
proposal.session_manager?.prompt_source,
|
||||
'# TORCH Session Manager\n\nRoute owner requests, establish ownership and priority, and keep routine coordination inside the fleet.\n',
|
||||
).trimEnd()}\n\n## Fleet evolution\n\nWhen recurring work has no coherent owner, or a durable specialist would materially improve context locality, ownership clarity, or verification, use \`torch_propose_domain\` to submit an evidence-backed Fleet change. Use \`torch_list_fleet_changes\` to follow its state. Never create, approve, or activate a persistent identity yourself; owner approval and CLI activation are separate. Also recommend merge or retirement when coordination cost exceeds the value of a persistent session.\n`;
|
||||
).trimEnd()}\n\n## Fleet evolution\n\nWhen recurring work has no coherent owner, or a durable specialist would materially improve context locality, ownership clarity, or verification, use \`torch_propose_domain\` to submit an evidence-backed Fleet change. When a specialist no longer earns its coordination cost, use \`torch_propose_domain_retirement\`; retirement preserves its branch and refuses active or unrecoverable work. Use \`torch_list_fleet_changes\` to follow change state. Never create, retire, approve, or activate a persistent identity yourself; owner approval and CLI activation are separate. Recommend a merge or split for owner review when boundaries should change but do not silently rewrite ownership.\n`;
|
||||
const files = new Map([
|
||||
['torch.yaml', jsonYaml(config)],
|
||||
['roster.yaml', jsonYaml(roster)],
|
||||
|
||||
+21
-1
@@ -56,6 +56,7 @@ export const TORCH_MCP_TOOL_NAMES = Object.freeze([
|
||||
'torch_list_fleet_changes',
|
||||
'torch_get_fleet_change',
|
||||
'torch_propose_domain',
|
||||
'torch_propose_domain_retirement',
|
||||
]);
|
||||
|
||||
function claimedIdentity(controlPlane, actorId, claim, field) {
|
||||
@@ -398,7 +399,7 @@ export function createTorchToolset(controlPlane, {
|
||||
if (evolutionService) {
|
||||
tools.set('torch_list_fleet_changes', {
|
||||
description: 'List durable proposed, approved, provisioning, or active Fleet organization changes.',
|
||||
schema: { state: z.enum(['proposed', 'approved', 'provisioning', 'active', 'rejected']).optional() },
|
||||
schema: { state: z.enum(['proposed', 'approved', 'provisioning', 'active', 'retired', 'rejected']).optional() },
|
||||
invoke: ({ state }) => ({ changes: evolutionService.list({ state }) }),
|
||||
});
|
||||
tools.set('torch_get_fleet_change', {
|
||||
@@ -436,6 +437,25 @@ export function createTorchToolset(controlPlane, {
|
||||
},
|
||||
}),
|
||||
});
|
||||
tools.set('torch_propose_domain_retirement', {
|
||||
description: 'Propose retiring a persistent domain when its coordination cost exceeds its continuing benefit. This never approves or activates retirement.',
|
||||
schema: {
|
||||
proposer: z.string().min(1).optional(), area_id: z.string().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),
|
||||
},
|
||||
invoke: ({ proposer, area_id: areaId, expected_benefit: benefit, ...input }) => evolutionService.proposeRetirement({
|
||||
...input, areaId,
|
||||
proposer: claimedIdentity(controlPlane, actorId, proposer, 'proposer'),
|
||||
expectedBenefit: {
|
||||
summary: benefit.summary, recurringWork: benefit.recurring_work,
|
||||
contextLocality: benefit.context_locality, coordinationCost: benefit.coordination_cost,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
@@ -174,3 +174,65 @@ test('SCN-cli-fleet-evolution: public CLI separates manager proposal, owner appr
|
||||
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();
|
||||
});
|
||||
Reference in new issue
Block a user