feat(fleet): orchestrator-mutable fleet — fleet add/remove (F5/R9) (#596)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending

This commit was merged in pull request #596.
This commit is contained in:
2026-06-21 23:26:21 +00:00
parent 60a309d5a4
commit 67df06f1c4
2 changed files with 677 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
addAgentToRoster,
buildAgentSendCommand,
buildAgentWatchAttachCommand,
buildAgentWatchCommand,
@@ -35,9 +36,11 @@ import {
parseTmuxListPanes,
parseTmuxListSessions,
registerFleetCommand,
removeAgentFromRoster,
resolveFleetPaths,
resolvePresetFilename,
RUNTIME_ACCEPTABLE_COMMANDS,
serializeRosterToYaml,
VERIFY_DEFAULT_TIMEOUT_MS,
VERIFY_POLL_INTERVAL_MS,
type AgentPsRow,
@@ -68,10 +71,12 @@ describe('registerFleetCommand', () => {
expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'add',
'init',
'install',
'install-systemd',
'ps',
'remove',
'restart',
'start',
'status',
@@ -2271,6 +2276,472 @@ describe('resolvePresetFilename', () => {
});
});
// ---------------------------------------------------------------------------
// Fleet Phase F5: orchestrator-mutable fleet — pure helper tests (R9)
// ---------------------------------------------------------------------------
describe('fleet add/remove — pure helpers', () => {
const baseRoster: FleetRoster = {
version: 1,
transport: 'tmux',
tmux: { socketName: 'mosaic-factory', holderSession: '_holder' },
defaults: { workingDirectory: '~/src' },
runtimes: { codex: { resetCommand: '/clear' } },
agents: [
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
{ name: 'coder0', runtime: 'codex', className: 'worker' },
],
};
it('addAgentToRoster appends a new agent and returns a new roster object', () => {
const newAgent = { name: 'reviewer0', runtime: 'pi', className: 'worker' };
const updated = addAgentToRoster(baseRoster, newAgent);
expect(updated.agents).toHaveLength(3);
expect(updated.agents[2]).toEqual(newAgent);
// immutable — original unchanged
expect(baseRoster.agents).toHaveLength(2);
expect(updated).not.toBe(baseRoster);
});
it('addAgentToRoster throws on duplicate name', () => {
expect(() =>
addAgentToRoster(baseRoster, { name: 'coder0', runtime: 'claude', className: 'worker' }),
).toThrow('Agent "coder0" already exists in the fleet roster.');
});
it('addAgentToRoster throws on invalid name (invalid characters)', () => {
expect(() =>
addAgentToRoster(baseRoster, { name: 'bad name!', runtime: 'claude', className: 'worker' }),
).toThrow('Invalid fleet agent name');
});
it('addAgentToRoster throws on empty name', () => {
expect(() =>
addAgentToRoster(baseRoster, { name: '', runtime: 'claude', className: 'worker' }),
).toThrow('Invalid fleet agent name');
});
it('removeAgentFromRoster removes the agent and returns new roster', () => {
const updated = removeAgentFromRoster(baseRoster, 'coder0');
expect(updated.agents).toHaveLength(1);
expect(updated.agents[0]!.name).toBe('orchestrator');
// immutable
expect(baseRoster.agents).toHaveLength(2);
expect(updated).not.toBe(baseRoster);
});
it('removeAgentFromRoster throws when agent not found', () => {
expect(() => removeAgentFromRoster(baseRoster, 'nonexistent')).toThrow(
'Agent "nonexistent" is not in the fleet roster.',
);
});
it('removeAgentFromRoster throws when removing the sole orchestrator (guard)', () => {
const rosterWithOnlyOrch: FleetRoster = {
...baseRoster,
agents: [{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' }],
};
expect(() => removeAgentFromRoster(rosterWithOnlyOrch, 'orchestrator')).toThrow(
'sole orchestrator',
);
});
it('removeAgentFromRoster allows removing an orchestrator when another remains', () => {
const rosterWithTwoOrchs: FleetRoster = {
...baseRoster,
agents: [
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
{ name: 'orchestrator2', runtime: 'claude', className: 'orchestrator' },
{ name: 'coder0', runtime: 'codex', className: 'worker' },
],
};
const updated = removeAgentFromRoster(rosterWithTwoOrchs, 'orchestrator');
expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator2', 'coder0']);
});
it('serializeRosterToYaml produces YAML that round-trips through loadFleetRoster', async () => {
const yaml = serializeRosterToYaml(baseRoster);
expect(typeof yaml).toBe('string');
expect(yaml).toContain('version: 1');
expect(yaml).toContain('name: orchestrator');
expect(yaml).toContain('name: coder0');
// Round-trip: write to disk and re-load
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(dir, 'roster.yaml');
try {
await writeFile(rosterPath, yaml);
const loaded = await loadFleetRoster(rosterPath);
expect(loaded.agents.map((a) => a.name)).toEqual(['orchestrator', 'coder0']);
expect(loaded.tmux.socketName).toBe('mosaic-factory');
expect(loaded.agents[0]!.className).toBe('orchestrator');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('serializeRosterToYaml round-trips optional fields (modelHint, workingDirectory)', async () => {
const rosterWithOptionals: FleetRoster = {
...baseRoster,
agents: [
{
name: 'orchestrator',
runtime: 'claude',
className: 'orchestrator',
modelHint: 'claude-3-5-sonnet',
workingDirectory: '/tmp/work',
persistentPersona: true,
resetBetweenTasks: false,
},
],
};
const yaml = serializeRosterToYaml(rosterWithOptionals);
expect(yaml).toContain('model_hint: claude-3-5-sonnet');
expect(yaml).toContain('working_directory: /tmp/work');
expect(yaml).toContain('persistent_persona: true');
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(dir, 'roster.yaml');
try {
await writeFile(rosterPath, yaml);
const loaded = await loadFleetRoster(rosterPath);
expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet');
expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work');
expect(loaded.agents[0]!.persistentPersona).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
// ---------------------------------------------------------------------------
// Fleet Phase F5: fleet add command tests
// ---------------------------------------------------------------------------
describe('fleet add command', () => {
let home: string;
afterEach(async () => {
if (home) {
await rm(home, { recursive: true, force: true });
}
});
async function makeHome(agents = ['orchestrator']): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-add-'));
await mkdir(join(dir, 'fleet', 'agents'), { recursive: true });
const agentLines = agents.map((name) => {
const cls = name === 'orchestrator' ? 'orchestrator' : 'worker';
return ` - name: ${name}\n runtime: claude\n class: ${cls}`;
});
await writeFile(
join(dir, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ...agentLines].join('\n'),
);
return dir;
}
it('appends agent to roster file and writes env file', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'codex',
'--class',
'worker',
]);
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
expect(roster.agents.map((a) => a.name)).toContain('coder0');
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
});
it('--no-start skips the start command', async () => {
home = await makeHome();
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'codex',
'--class',
'worker',
'--no-start',
]);
// No start command should have been issued
const startCalls = calls.filter((c) => c.includes('start'));
expect(startCalls).toHaveLength(0);
});
it('without --no-start, issues start command for the new agent', async () => {
home = await makeHome();
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'codex',
'--class',
'worker',
]);
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-agent@coder0.service']);
});
it('throws when adding a duplicate agent name', async () => {
home = await makeHome(['orchestrator', 'coder0']);
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await expect(
program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'codex',
'--class',
'worker',
]),
).rejects.toThrow('already exists');
});
it('throws when runtime is invalid', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await expect(
program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'notaruntime',
'--class',
'worker',
]),
).rejects.toThrow('Invalid runtime');
});
it('accepts optional --model and --working-dir options', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder0',
'--runtime',
'claude',
'--class',
'worker',
'--model',
'claude-sonnet',
'--working-dir',
'/tmp/work',
]);
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
const agent = roster.agents.find((a) => a.name === 'coder0');
expect(agent?.modelHint).toBe('claude-sonnet');
expect(agent?.workingDirectory).toBe('/tmp/work');
});
});
// ---------------------------------------------------------------------------
// Fleet Phase F5: fleet remove command tests
// ---------------------------------------------------------------------------
describe('fleet remove command', () => {
let home: string;
afterEach(async () => {
if (home) {
await rm(home, { recursive: true, force: true });
}
});
async function makeHome(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-remove-'));
await mkdir(join(dir, 'fleet', 'agents'), { recursive: true });
await mkdir(join(dir, 'fleet', 'run'), { recursive: true });
await writeFile(
join(dir, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: orchestrator',
' runtime: claude',
' class: orchestrator',
' - name: coder0',
' runtime: codex',
' class: worker',
].join('\n'),
);
// Create env and heartbeat files for coder0
await writeFile(join(dir, 'fleet', 'agents', 'coder0.env'), 'MOSAIC_AGENT_NAME=coder0\n');
await writeFile(join(dir, 'fleet', 'run', 'coder0.hb'), 'ts=2026-01-01T00:00:00.000Z\n');
return dir;
}
it('removes agent from roster and writes back', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
expect(roster.agents.map((a) => a.name)).not.toContain('coder0');
expect(roster.agents.map((a) => a.name)).toContain('orchestrator');
});
it('stop is called before roster write (stop is the first runner call)', async () => {
home = await makeHome();
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
expect(calls[0]).toEqual(['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service']);
});
it('stop failure is non-fatal — warns but still removes from roster', async () => {
home = await makeHome();
const stderrMessages: string[] = [];
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
stderrMessages.push(String(msg));
return true;
});
const runner: CommandRunner = async (command, args) => {
if (args.includes('stop')) {
return { stdout: '', stderr: 'unit not found', exitCode: 5 };
}
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
// Must not reject
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']),
).resolves.toBeDefined();
// Agent should be removed from roster despite stop failure
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
expect(roster.agents.map((a) => a.name)).not.toContain('coder0');
// Warning must have been emitted
expect(stderrMessages.join('')).toMatch(/Warning/);
} finally {
stderrSpy.mockRestore();
}
});
it('--keep-files skips env file deletion', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0', '--keep-files']);
// Env file should still exist
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
});
it('env file is removed by default (no --keep-files)', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
await expect(readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8')).rejects.toThrow();
});
it('removing the sole orchestrator throws with a clear error about the guard', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
// First remove the worker so only the orchestrator remains
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
// Now attempt to remove the sole orchestrator
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'orchestrator']),
).rejects.toThrow('sole orchestrator');
});
});
describe('fleet init wizard', () => {
let cleanup: string | undefined;