feat(fleet): config-type presets + AI-free init wizard (F1) (#591)
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 #591.
This commit is contained in:
2026-06-21 23:07:41 +00:00
parent bb7d549080
commit ca19d57bba
6 changed files with 529 additions and 9 deletions

View File

@@ -17,8 +17,10 @@ import {
buildTmuxListPanesCommand,
buildTmuxListSessionsCommand,
classifySendResult,
countOrchestrators,
detectDrift,
enableFleetUnits,
FLEET_PROFILES,
generateAgentEnv,
getDefaultOperatorSourceLabel,
getDefaultTenantAndHost,
@@ -28,16 +30,19 @@ import {
loadFleetRoster,
mergeAgentEnv,
parseHeartbeat,
parseInitProfile,
parseSystemdShow,
parseTmuxListPanes,
parseTmuxListSessions,
registerFleetCommand,
resolveFleetPaths,
resolvePresetFilename,
RUNTIME_ACCEPTABLE_COMMANDS,
VERIFY_DEFAULT_TIMEOUT_MS,
VERIFY_POLL_INTERVAL_MS,
type AgentPsRow,
type CommandRunner,
type FleetProfile,
type FleetRoster,
type InteractiveRunner,
type SleepFn,
@@ -2132,3 +2137,270 @@ describe('agent send --verify', () => {
expect(VERIFY_DEFAULT_TIMEOUT_MS).toBe(6_000);
});
});
// ---------------------------------------------------------------------------
// Fleet Phase F1: config-type presets + AI-free init wizard
// ---------------------------------------------------------------------------
describe('fleet preset rosters', () => {
const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples');
it.each(['general', 'coding', 'research', 'hybrid'] as FleetProfile[])(
'%s preset: loads via loadFleetRoster and has exactly one orchestrator',
async (preset) => {
const rosterPath = join(examplesDir, `${preset}.yaml`);
const roster = await loadFleetRoster(rosterPath);
expect(countOrchestrators(roster)).toBe(1);
expect(roster.agents.find((a) => a.name === 'orchestrator')).toBeDefined();
},
);
it('general preset: orchestrator + one generalist worker', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'general.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'generalist']);
expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude');
expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi');
});
it('coding preset: orchestrator + coder0 + coder1 + reviewer', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual([
'orchestrator',
'coder0',
'coder1',
'reviewer',
]);
});
it('research preset: orchestrator + researcher0 + researcher1 + analyst', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'research.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual([
'orchestrator',
'researcher0',
'researcher1',
'analyst',
]);
});
it('hybrid preset: orchestrator + coder0 + researcher0 + reviewer', async () => {
const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml'));
expect(roster.agents.map((a) => a.name)).toEqual([
'orchestrator',
'coder0',
'researcher0',
'reviewer',
]);
});
it('worker agents in new presets use pi runtime with model_hint openai-codex/gpt-5.5:high', async () => {
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
const workers = roster.agents.filter((a) => a.name !== 'orchestrator');
for (const worker of workers) {
expect(worker.runtime).toBe('pi');
expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high');
}
}
});
it('orchestrator in new presets uses claude runtime with persistent_persona', async () => {
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
const orch = roster.agents.find((a) => a.name === 'orchestrator');
expect(orch?.runtime).toBe('claude');
expect(orch?.persistentPersona).toBe(true);
}
});
it('new presets are sanitized: no operator identity tokens', async () => {
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
const text = await readFile(join(examplesDir, `${preset}.yaml`), 'utf8');
expect(text).not.toMatch(/jarvis|jason|woltje/i);
// working_directory must not reference ~/src or /home
expect(text).not.toMatch(/~\/src|\/home\//);
}
});
});
describe('parseInitProfile', () => {
it('accepts all six fleet profiles', () => {
expect(parseInitProfile('general')).toBe('general');
expect(parseInitProfile('coding')).toBe('coding');
expect(parseInitProfile('research')).toBe('research');
expect(parseInitProfile('hybrid')).toBe('hybrid');
expect(parseInitProfile('minimal')).toBe('minimal');
expect(parseInitProfile('local-canary')).toBe('local-canary');
});
it('rejects unknown profiles with a message listing all valid names', () => {
expect(() => parseInitProfile('typo')).toThrow('Unsupported fleet profile');
expect(() => parseInitProfile('typo')).toThrow('general');
expect(() => parseInitProfile('typo')).toThrow('coding');
});
it('FLEET_PROFILES contains all six valid profile names', () => {
expect(FLEET_PROFILES).toContain('general');
expect(FLEET_PROFILES).toContain('coding');
expect(FLEET_PROFILES).toContain('research');
expect(FLEET_PROFILES).toContain('hybrid');
expect(FLEET_PROFILES).toContain('minimal');
expect(FLEET_PROFILES).toContain('local-canary');
});
});
describe('resolvePresetFilename', () => {
it.each(FLEET_PROFILES)('maps %s to %s.yaml', (profile) => {
expect(resolvePresetFilename(profile)).toBe(`${profile}.yaml`);
});
});
describe('fleet init wizard', () => {
let cleanup: string | undefined;
afterEach(async () => {
if (cleanup) {
await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
}
});
it('defaults to general when stdin is not a TTY and no --profile is given', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
const frameworkRoot = resolve(process.cwd(), 'framework');
const stderrMessages: string[] = [];
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
stderrMessages.push(String(msg));
return true;
});
const program = new Command();
program.exitOverride();
// isStdinTTY: false simulates non-interactive environment
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
try {
await program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--write',
]);
const content = await readFile(rosterPath, 'utf8');
// Should have written the general preset
expect(content).toContain('name: orchestrator');
expect(content).toContain('name: generalist');
// Stderr should explain the fallback
expect(stderrMessages.join('')).toMatch(/defaulting to fleet profile "general"/);
} finally {
stderrSpy.mockRestore();
}
});
it('uses --profile to select preset without wizard (non-TTY path)', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
const frameworkRoot = resolve(process.cwd(), 'framework');
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
try {
await program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'coding',
'--write',
]);
const content = await readFile(rosterPath, 'utf8');
expect(content).toContain('name: coder0');
expect(content).toContain('name: reviewer');
} finally {
// cleanup handled by afterEach
}
});
it('written roster has exactly one orchestrator agent (countOrchestrators validation)', async () => {
cleanup = await tempDir();
const frameworkRoot = resolve(process.cwd(), 'framework');
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
const rosterPath = join(cleanup, `${preset}-roster.yaml`);
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
await program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
preset,
'--write',
]);
const roster = await loadFleetRoster(rosterPath);
expect(countOrchestrators(roster)).toBe(1);
}
});
it('re-init with --write and existing roster requires --force (R8 idempotency)', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
const frameworkRoot = resolve(process.cwd(), 'framework');
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
// First write
await program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'general',
'--write',
]);
// Second write without --force must fail
await expect(
program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'general',
'--write',
]),
).rejects.toThrow('Fleet roster already exists');
// With --force must succeed
await program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'coding',
'--write',
'--force',
]);
const content = await readFile(rosterPath, 'utf8');
expect(content).toContain('name: coder0');
});
});