feat(fleet): orchestrator-mutable fleet — fleet add/remove (F5/R9) (#596)
This commit was merged in pull request #596.
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user