Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2925 lines
98 KiB
TypeScript
2925 lines
98 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join, resolve } from 'node:path';
|
||
import { Command } from 'commander';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import {
|
||
addAgentToRoster,
|
||
buildAgentSendCommand,
|
||
buildAgentWatchAttachCommand,
|
||
buildAgentWatchCommand,
|
||
buildAgentWatchCreateViewerCommand,
|
||
buildAgentWatchKillViewerCommand,
|
||
buildAgentVerifyAcceptedCommand,
|
||
buildEnableLingerCommand,
|
||
buildFleetServiceCommand,
|
||
buildSystemdEnableCommand,
|
||
buildSystemdShowCommand,
|
||
buildTmuxListPanesCommand,
|
||
buildTmuxListSessionsCommand,
|
||
classifySendResult,
|
||
countOrchestrators,
|
||
detectDrift,
|
||
enableFleetUnits,
|
||
FLEET_PROFILES,
|
||
generateAgentEnv,
|
||
getDefaultOperatorSourceLabel,
|
||
getDefaultTenantAndHost,
|
||
getRosterAgent,
|
||
heartbeatPath,
|
||
isSendAccepted,
|
||
loadFleetRoster,
|
||
mergeAgentEnv,
|
||
parseHeartbeat,
|
||
parseInitProfile,
|
||
parseSystemdShow,
|
||
parseTmuxListPanes,
|
||
parseTmuxListSessions,
|
||
registerFleetCommand,
|
||
removeAgentFromRoster,
|
||
resolveFleetPaths,
|
||
resolvePresetFilename,
|
||
RUNTIME_ACCEPTABLE_COMMANDS,
|
||
serializeRosterToYaml,
|
||
VERIFY_DEFAULT_TIMEOUT_MS,
|
||
VERIFY_POLL_INTERVAL_MS,
|
||
type AgentPsRow,
|
||
type CommandRunner,
|
||
type FleetProfile,
|
||
type FleetRoster,
|
||
type InteractiveRunner,
|
||
type SleepFn,
|
||
} from './fleet.js';
|
||
import { registerAgentCommand } from './agent.js';
|
||
|
||
function buildProgram(): Command {
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program);
|
||
registerAgentCommand(program);
|
||
return program;
|
||
}
|
||
|
||
async function tempDir(): Promise<string> {
|
||
return mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
}
|
||
|
||
describe('registerFleetCommand', () => {
|
||
it('registers local canary fleet subcommands', () => {
|
||
const program = buildProgram();
|
||
const fleet = program.commands.find((command) => command.name() === 'fleet');
|
||
|
||
expect(fleet).toBeDefined();
|
||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||
'add',
|
||
'init',
|
||
'install',
|
||
'install-systemd',
|
||
'ps',
|
||
'remove',
|
||
'restart',
|
||
'start',
|
||
'status',
|
||
'stop',
|
||
'verify',
|
||
]);
|
||
});
|
||
|
||
it('adds fleet-backed agent subcommands without removing existing options', () => {
|
||
const program = buildProgram();
|
||
const agent = program.commands.find((command) => command.name() === 'agent');
|
||
|
||
expect(agent).toBeDefined();
|
||
expect(agent!.options.map((option) => option.long)).toContain('--list');
|
||
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
|
||
'reset',
|
||
'roster',
|
||
'send',
|
||
'status',
|
||
'tail',
|
||
'watch',
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('fleet roster parsing', () => {
|
||
let cleanup: string | undefined;
|
||
|
||
afterEach(async () => {
|
||
if (cleanup) {
|
||
await rm(cleanup, { recursive: true, force: true });
|
||
cleanup = undefined;
|
||
}
|
||
});
|
||
|
||
it('defaults local canary rosters to the isolated mosaic-factory socket', async () => {
|
||
cleanup = await tempDir();
|
||
const rosterPath = join(cleanup, 'roster.yaml');
|
||
await writeFile(
|
||
rosterPath,
|
||
[
|
||
'version: 1',
|
||
'transport: tmux',
|
||
'agents:',
|
||
' - name: canary-pi',
|
||
' runtime: pi',
|
||
' class: canary',
|
||
].join('\n'),
|
||
);
|
||
|
||
const roster = await loadFleetRoster(rosterPath);
|
||
|
||
expect(roster.tmux.socketName).toBe('mosaic-factory');
|
||
expect(roster.tmux.holderSession).toBe('_holder');
|
||
expect(roster.agents).toHaveLength(1);
|
||
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
||
});
|
||
|
||
it('generates deterministic per-agent EnvironmentFile content', async () => {
|
||
cleanup = await tempDir();
|
||
const rosterPath = join(cleanup, 'roster.json');
|
||
await writeFile(
|
||
rosterPath,
|
||
JSON.stringify({
|
||
version: 1,
|
||
transport: 'tmux',
|
||
tmux: { socket_name: 'mosaic-factory' },
|
||
defaults: { working_directory: '/srv/mosaic' },
|
||
agents: [{ name: 'coder0', runtime: 'codex', class: 'implementer' }],
|
||
}),
|
||
);
|
||
const roster = await loadFleetRoster(rosterPath);
|
||
|
||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||
[
|
||
'MOSAIC_AGENT_NAME=coder0',
|
||
'MOSAIC_AGENT_RUNTIME=codex',
|
||
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
||
'MOSAIC_TMUX_SOCKET=mosaic-factory',
|
||
'',
|
||
].join('\n'),
|
||
);
|
||
});
|
||
|
||
it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => {
|
||
const generated = [
|
||
'MOSAIC_AGENT_NAME=coder0',
|
||
'MOSAIC_AGENT_RUNTIME=codex',
|
||
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
||
'MOSAIC_TMUX_SOCKET=mosaic-factory',
|
||
'',
|
||
].join('\n');
|
||
const existing = [
|
||
'MOSAIC_AGENT_NAME=old-name',
|
||
'MOSAIC_AGENT_RUNTIME=old-runtime',
|
||
'MOSAIC_AGENT_WORKDIR=/srv/old',
|
||
'MOSAIC_TMUX_SOCKET=old-socket',
|
||
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
||
'# site note',
|
||
'',
|
||
].join('\n');
|
||
|
||
expect(mergeAgentEnv(generated, existing)).toBe(
|
||
[
|
||
'MOSAIC_AGENT_NAME=coder0',
|
||
'MOSAIC_AGENT_RUNTIME=codex',
|
||
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
||
'MOSAIC_TMUX_SOCKET=mosaic-factory',
|
||
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
||
'# site note',
|
||
'',
|
||
].join('\n'),
|
||
);
|
||
});
|
||
|
||
it('rejects unknown roster fields instead of silently defaulting', async () => {
|
||
cleanup = await tempDir();
|
||
const rosterPath = join(cleanup, 'roster.yaml');
|
||
await writeFile(
|
||
rosterPath,
|
||
[
|
||
'version: 1',
|
||
'transport: tmux',
|
||
'tmux:',
|
||
' socketNamee: prod-fleet',
|
||
'agents:',
|
||
' - name: canary-pi',
|
||
' runtime: pi',
|
||
].join('\n'),
|
||
);
|
||
|
||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||
'Fleet roster tmux has unknown field(s): socketNamee.',
|
||
);
|
||
});
|
||
|
||
it('rejects wrong-typed roster fields instead of silently defaulting', async () => {
|
||
cleanup = await tempDir();
|
||
const rosterPath = join(cleanup, 'roster.json');
|
||
await writeFile(
|
||
rosterPath,
|
||
JSON.stringify({
|
||
version: 1,
|
||
transport: 'tmux',
|
||
tmux: { socket_name: 123 },
|
||
defaults: { working_directory: '/srv/mosaic' },
|
||
agents: [{ name: 'canary-pi', runtime: 'pi' }],
|
||
}),
|
||
);
|
||
|
||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||
'Fleet roster tmux socket_name must be a string.',
|
||
);
|
||
});
|
||
|
||
it('rejects wrong-typed agent fields', async () => {
|
||
cleanup = await tempDir();
|
||
const rosterPath = join(cleanup, 'roster.json');
|
||
await writeFile(
|
||
rosterPath,
|
||
JSON.stringify({
|
||
version: 1,
|
||
transport: 'tmux',
|
||
agents: [{ name: 'canary-pi', runtime: 42 }],
|
||
}),
|
||
);
|
||
|
||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||
'Fleet roster agent "canary-pi" runtime must be a string.',
|
||
);
|
||
});
|
||
|
||
it('rejects duplicate agent names before install can overwrite env files', async () => {
|
||
cleanup = await tempDir();
|
||
const rosterPath = join(cleanup, 'roster.yaml');
|
||
await writeFile(
|
||
rosterPath,
|
||
[
|
||
'version: 1',
|
||
'transport: tmux',
|
||
'agents:',
|
||
' - name: canary-pi',
|
||
' runtime: pi',
|
||
' - name: canary-pi',
|
||
' runtime: codex',
|
||
].join('\n'),
|
||
);
|
||
|
||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||
'Fleet roster has duplicate agent name: canary-pi.',
|
||
);
|
||
});
|
||
|
||
it('ships generic minimal and local-canary examples without site-specific defaults', async () => {
|
||
const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples');
|
||
const minimal = await loadFleetRoster(join(examplesDir, 'minimal.yaml'));
|
||
const localCanaryText = await readFile(join(examplesDir, 'local-canary.yaml'), 'utf8');
|
||
const localCanary = await loadFleetRoster(join(examplesDir, 'local-canary.yaml'));
|
||
|
||
expect(minimal.agents.map((agent) => agent.name)).toEqual(['canary-pi']);
|
||
expect(localCanary.tmux.socketName).toBe('mosaic-factory');
|
||
expect(localCanary.agents.map((agent) => agent.name)).toEqual(['lead', 'coder0', 'reviewer0']);
|
||
expect(localCanaryText).not.toMatch(/usc|ultron|secrev/i);
|
||
});
|
||
});
|
||
|
||
describe('fleet command construction', () => {
|
||
it('builds exact systemd user commands for holder and agent operations', () => {
|
||
expect(buildFleetServiceCommand('status')).toEqual([
|
||
'systemctl',
|
||
'--user',
|
||
'status',
|
||
'mosaic-tmux-holder.service',
|
||
]);
|
||
expect(buildFleetServiceCommand('restart', 'coder0')).toEqual([
|
||
'systemctl',
|
||
'--user',
|
||
'restart',
|
||
'mosaic-agent@coder0.service',
|
||
]);
|
||
});
|
||
|
||
it('builds socket-scoped agent send commands', () => {
|
||
const paths = resolveFleetPaths('/home/test/.config/mosaic');
|
||
expect(
|
||
buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory', 'operator:mosaic-cli'),
|
||
).toEqual([
|
||
'/home/test/.config/mosaic/tools/tmux/agent-send.sh',
|
||
'-L',
|
||
'mosaic-factory',
|
||
'-S',
|
||
'operator:mosaic-cli',
|
||
'-s',
|
||
'coder0',
|
||
'-m',
|
||
'hello',
|
||
]);
|
||
});
|
||
|
||
it('runs fleet status through injected runner without touching tmux in tests', async () => {
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
return { stdout: 'ok\n', stderr: '', exitCode: 0 };
|
||
};
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { runner });
|
||
|
||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||
|
||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||
});
|
||
|
||
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
|
||
const home = await tempDir();
|
||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
rosterPath,
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
return { stdout: 'active (exited)\n', stderr: '', exitCode: 0 };
|
||
};
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']);
|
||
expect(calls).toEqual([
|
||
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=_holder:0.0'],
|
||
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=coder0:0.0'],
|
||
]);
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('writes init output to the explicit roster path', async () => {
|
||
const home = await tempDir();
|
||
const rosterPath = join(home, 'custom', 'roster.yaml');
|
||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'fleet',
|
||
'--roster',
|
||
rosterPath,
|
||
'init',
|
||
'--profile',
|
||
'minimal',
|
||
'--write',
|
||
]);
|
||
|
||
const content = await readFile(rosterPath, 'utf8');
|
||
expect(content).toContain('name: canary-pi');
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('refuses to overwrite an existing roster unless --force is provided', async () => {
|
||
const home = await tempDir();
|
||
const rosterPath = join(home, 'custom', 'roster.yaml');
|
||
await mkdir(dirname(rosterPath), { recursive: true });
|
||
await writeFile(rosterPath, 'site-owned: true\n');
|
||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
|
||
|
||
try {
|
||
await expect(
|
||
program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'fleet',
|
||
'--roster',
|
||
rosterPath,
|
||
'init',
|
||
'--profile',
|
||
'minimal',
|
||
'--write',
|
||
]),
|
||
).rejects.toThrow('Fleet roster already exists');
|
||
expect(await readFile(rosterPath, 'utf8')).toBe('site-owned: true\n');
|
||
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'fleet',
|
||
'--roster',
|
||
rosterPath,
|
||
'init',
|
||
'--profile',
|
||
'minimal',
|
||
'--write',
|
||
'--force',
|
||
]);
|
||
expect(await readFile(rosterPath, 'utf8')).toContain('name: canary-pi');
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('rejects unknown init profiles instead of silently falling back', async () => {
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') });
|
||
|
||
await expect(
|
||
program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'typo']),
|
||
).rejects.toThrow('Unsupported fleet profile');
|
||
});
|
||
|
||
it('sets process exitCode when status runner fails', async () => {
|
||
const originalExitCode = process.exitCode;
|
||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { runner });
|
||
|
||
try {
|
||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||
expect(process.exitCode).toBe(3);
|
||
} finally {
|
||
process.exitCode = originalExitCode;
|
||
stderrSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it('loads default fleet/roster.json when roster.yaml is absent', async () => {
|
||
const home = await tempDir();
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.json'),
|
||
JSON.stringify({
|
||
version: 1,
|
||
transport: 'tmux',
|
||
agents: [{ name: 'json-canary', runtime: 'pi' }],
|
||
}),
|
||
);
|
||
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 });
|
||
|
||
try {
|
||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status', 'json-canary']);
|
||
expect(calls).toEqual([
|
||
['systemctl', '--user', 'status', 'mosaic-agent@json-canary.service'],
|
||
]);
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('starts the holder before agents and stops agents before the holder', async () => {
|
||
const home = await tempDir();
|
||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
rosterPath,
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
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 });
|
||
|
||
try {
|
||
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
|
||
await program.parseAsync(['node', 'mosaic', 'fleet', 'stop']);
|
||
|
||
expect(calls).toEqual([
|
||
['systemctl', '--user', 'start', 'mosaic-tmux-holder.service'],
|
||
['systemctl', '--user', 'start', 'mosaic-agent@coder0.service'],
|
||
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
|
||
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
|
||
]);
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
|
||
const home = await tempDir();
|
||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
rosterPath,
|
||
[
|
||
'version: 1',
|
||
'transport: tmux',
|
||
'agents:',
|
||
' - name: coder0',
|
||
' runtime: codex',
|
||
' - name: reviewer0',
|
||
' runtime: pi',
|
||
].join('\n'),
|
||
);
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
if (args.includes('mosaic-agent@coder0.service')) {
|
||
return { stdout: '', stderr: 'coder0 failed\n', exitCode: 1 };
|
||
}
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await expect(program.parseAsync(['node', 'mosaic', 'fleet', 'stop'])).rejects.toThrow(
|
||
'Fleet stop completed with 1 failure(s)',
|
||
);
|
||
expect(calls).toEqual([
|
||
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
|
||
['systemctl', '--user', 'stop', 'mosaic-agent@reviewer0.service'],
|
||
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
|
||
]);
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('rejects install-systemd with a non-default Mosaic home because units use %h/.config/mosaic', async () => {
|
||
const home = await tempDir();
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, {
|
||
mosaicHome: home,
|
||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||
});
|
||
|
||
try {
|
||
await expect(
|
||
program.parseAsync(['node', 'mosaic', 'fleet', 'install-systemd']),
|
||
).rejects.toThrow('install-systemd only supports the default Mosaic home');
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it.each(['start', 'stop', 'restart', 'status'] as const)(
|
||
'rejects single-agent %s for agents outside the roster',
|
||
async (action) => {
|
||
const home = await tempDir();
|
||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
rosterPath,
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await expect(
|
||
program.parseAsync(['node', 'mosaic', 'fleet', action, 'typo']),
|
||
).rejects.toThrow('Agent "typo" is not in the fleet roster');
|
||
expect(runner).not.toHaveBeenCalled();
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
},
|
||
);
|
||
|
||
it('loads default fleet/roster.json for agent commands when roster.yaml is absent', async () => {
|
||
const home = await tempDir();
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.json'),
|
||
JSON.stringify({
|
||
version: 1,
|
||
transport: 'tmux',
|
||
agents: [{ name: 'json-agent', runtime: 'pi' }],
|
||
}),
|
||
);
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']);
|
||
expect(calls).toEqual([
|
||
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=json-agent:0.0'],
|
||
]);
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('passes a deterministic operator source label for agent sends', async () => {
|
||
const home = await tempDir();
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
JSON.stringify({
|
||
version: 1,
|
||
transport: 'tmux',
|
||
agents: [{ name: 'json-agent', runtime: 'pi' }],
|
||
}),
|
||
);
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'agent',
|
||
'send',
|
||
'json-agent',
|
||
'--message',
|
||
'status check',
|
||
]);
|
||
expect(calls).toEqual([
|
||
[
|
||
join(home, 'tools', 'tmux', 'agent-send.sh'),
|
||
'-L',
|
||
'mosaic-factory',
|
||
'-S',
|
||
getDefaultOperatorSourceLabel(),
|
||
'-s',
|
||
'json-agent',
|
||
'-m',
|
||
'status check',
|
||
],
|
||
]);
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('allows agent sends to override the source label explicitly', async () => {
|
||
const home = await tempDir();
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
JSON.stringify({
|
||
version: 1,
|
||
transport: 'tmux',
|
||
agents: [{ name: 'coder0', runtime: 'codex' }],
|
||
}),
|
||
);
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'agent',
|
||
'send',
|
||
'coder0',
|
||
'--message',
|
||
'handoff',
|
||
'--source-label',
|
||
'lead:manual',
|
||
]);
|
||
expect(calls).toEqual([
|
||
[
|
||
join(home, 'tools', 'tmux', 'agent-send.sh'),
|
||
'-L',
|
||
'mosaic-factory',
|
||
'-S',
|
||
'lead:manual',
|
||
'-s',
|
||
'coder0',
|
||
'-m',
|
||
'handoff',
|
||
],
|
||
]);
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('rejects agent status typos before invoking the runner', async () => {
|
||
const home = await tempDir();
|
||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
rosterPath,
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await expect(
|
||
program.parseAsync(['node', 'mosaic', 'agent', 'status', 'typo']),
|
||
).rejects.toThrow('Agent "typo" is not in the fleet roster');
|
||
expect(runner).not.toHaveBeenCalled();
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('keeps fleet framework assets in the published package file list', async () => {
|
||
const packageJson = JSON.parse(
|
||
await readFile(resolve(process.cwd(), 'package.json'), 'utf8'),
|
||
) as {
|
||
files?: string[];
|
||
};
|
||
|
||
expect(packageJson.files).toEqual(expect.arrayContaining(['dist', 'framework']));
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Phase-2 observability — unit tests (FR-1, FR-3, FR-5, FR-6)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('fleet ps — command construction', () => {
|
||
it('builds exact systemd show command for an agent unit', () => {
|
||
expect(buildSystemdShowCommand('canary-pi')).toEqual([
|
||
'systemctl',
|
||
'--user',
|
||
'show',
|
||
'mosaic-agent@canary-pi.service',
|
||
'-p',
|
||
'ActiveState',
|
||
'-p',
|
||
'SubState',
|
||
'-p',
|
||
'UnitFileState',
|
||
]);
|
||
});
|
||
|
||
it('builds exact tmux list-panes command with the correct format string', () => {
|
||
expect(buildTmuxListPanesCommand('canary-pi', 'mosaic-factory')).toEqual([
|
||
'tmux',
|
||
'-L',
|
||
'mosaic-factory',
|
||
'list-panes',
|
||
'-t',
|
||
'=canary-pi:0.0',
|
||
'-F',
|
||
'#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity}',
|
||
]);
|
||
});
|
||
|
||
it('uses DEFAULT_SOCKET_NAME when socket is omitted from list-panes', () => {
|
||
const cmd = buildTmuxListPanesCommand('canary-pi');
|
||
expect(cmd[2]).toBe('mosaic-factory');
|
||
});
|
||
|
||
it('derives heartbeat path under ~/.config/mosaic/fleet/run/', () => {
|
||
const home = '/home/test/.config/mosaic';
|
||
expect(heartbeatPath('canary-pi', home)).toBe(
|
||
'/home/test/.config/mosaic/fleet/run/canary-pi.hb',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('fleet ps — heartbeat parsing', () => {
|
||
const NOW = 1_700_000_000_000; // fixed epoch ms for deterministic tests
|
||
|
||
it('parses a healthy heartbeat file', () => {
|
||
const ts = new Date(NOW - 10_000).toISOString(); // 10s ago — within 3×15s = 45s
|
||
const content = `ts=${ts}\npid=12345\nstatus=ok\n`;
|
||
const hb = parseHeartbeat(content, NOW);
|
||
expect(hb.health).toBe('healthy');
|
||
expect(hb.pid).toBe(12345);
|
||
expect(hb.status).toBe('ok');
|
||
expect(hb.ageMs).toBe(10_000);
|
||
});
|
||
|
||
it('reports stale when heartbeat is older than 3×interval', () => {
|
||
const ts = new Date(NOW - 60_000).toISOString(); // 60s ago > 45s threshold
|
||
const content = `ts=${ts}\npid=99\nstatus=busy\n`;
|
||
const hb = parseHeartbeat(content, NOW);
|
||
expect(hb.health).toBe('stale');
|
||
expect(hb.status).toBe('busy');
|
||
});
|
||
|
||
it('reports unknown when heartbeat file is missing (null input)', () => {
|
||
const hb = parseHeartbeat(null, NOW);
|
||
expect(hb.health).toBe('unknown');
|
||
expect(hb.ts).toBeNull();
|
||
expect(hb.pid).toBeNull();
|
||
expect(hb.ageMs).toBeNull();
|
||
});
|
||
|
||
it('tolerates missing fields in heartbeat file', () => {
|
||
const hb = parseHeartbeat('ts=not-a-date\n', NOW);
|
||
expect(hb.health).toBe('unknown');
|
||
expect(hb.ts).toBeNull();
|
||
});
|
||
|
||
it('honors MOSAIC_HEARTBEAT_INTERVAL for the freshness threshold', () => {
|
||
const prev = process.env.MOSAIC_HEARTBEAT_INTERVAL;
|
||
try {
|
||
// A 60s-old beat is STALE at the default 15s interval (3x15 = 45s)...
|
||
const ts = new Date(NOW - 60_000).toISOString();
|
||
const content = `ts=${ts}\npid=1\nstatus=ok\n`;
|
||
delete process.env.MOSAIC_HEARTBEAT_INTERVAL;
|
||
expect(parseHeartbeat(content, NOW).health).toBe('stale');
|
||
// ...but HEALTHY when the operator widened the interval to 30s (3x30 = 90s).
|
||
process.env.MOSAIC_HEARTBEAT_INTERVAL = '30';
|
||
expect(parseHeartbeat(content, NOW).health).toBe('healthy');
|
||
} finally {
|
||
if (prev === undefined) delete process.env.MOSAIC_HEARTBEAT_INTERVAL;
|
||
else process.env.MOSAIC_HEARTBEAT_INTERVAL = prev;
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('fleet ps — systemd show parsing', () => {
|
||
it('parses ActiveState, SubState, UnitFileState from systemctl show output', () => {
|
||
const output = 'ActiveState=active\nSubState=running\nUnitFileState=enabled\n';
|
||
expect(parseSystemdShow(output)).toEqual({
|
||
ActiveState: 'active',
|
||
SubState: 'running',
|
||
UnitFileState: 'enabled',
|
||
});
|
||
});
|
||
|
||
it('defaults missing keys to "unknown"', () => {
|
||
const result = parseSystemdShow('ActiveState=inactive\n');
|
||
expect(result.SubState).toBe('unknown');
|
||
expect(result.UnitFileState).toBe('unknown');
|
||
});
|
||
});
|
||
|
||
describe('fleet ps — tmux list-panes parsing', () => {
|
||
const NOW_MS = 1_700_000_000_000;
|
||
|
||
it('parses alive pane with pid, command, and idle time', () => {
|
||
const activityEpoch = Math.floor((NOW_MS - 30_000) / 1000); // 30s ago
|
||
const output = `12345 claude 0 ${activityEpoch}\n`;
|
||
const result = parseTmuxListPanes(output, NOW_MS);
|
||
expect(result.pid).toBe(12345);
|
||
expect(result.command).toBe('claude');
|
||
expect(result.dead).toBe(false);
|
||
expect(result.idleSeconds).toBe(30);
|
||
});
|
||
|
||
it('reports dead pane when pane_dead=1', () => {
|
||
const output = `0 bash 1 0\n`;
|
||
const result = parseTmuxListPanes(output, NOW_MS);
|
||
expect(result.dead).toBe(true);
|
||
});
|
||
|
||
it('returns nulls for empty pane output', () => {
|
||
const result = parseTmuxListPanes('', NOW_MS);
|
||
expect(result.pid).toBeNull();
|
||
expect(result.command).toBeNull();
|
||
expect(result.dead).toBe(true);
|
||
expect(result.idleSeconds).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('fleet ps — drift detection', () => {
|
||
it('flags drift when roster says pi but pane runs python3', () => {
|
||
expect(detectDrift('pi', 'python3')).toBe(true);
|
||
});
|
||
|
||
it('flags drift when roster says claude but pane runs dogfood-agent.py', () => {
|
||
expect(detectDrift('claude', 'dogfood-agent.py')).toBe(true);
|
||
});
|
||
|
||
it('does NOT flag drift when pane command matches the roster runtime', () => {
|
||
expect(detectDrift('claude', 'claude')).toBe(false);
|
||
expect(detectDrift('codex', 'codex')).toBe(false);
|
||
expect(detectDrift('pi', 'pi')).toBe(false);
|
||
expect(detectDrift('opencode', 'opencode')).toBe(false);
|
||
});
|
||
|
||
it('does NOT flag drift for unknown/custom runtimes (no canonical mapping)', () => {
|
||
expect(detectDrift('custom-runtime', 'anything')).toBe(false);
|
||
});
|
||
|
||
it('does NOT flag drift when pane command is null (pane dead)', () => {
|
||
expect(detectDrift('pi', null)).toBe(false);
|
||
});
|
||
|
||
it('does NOT flag drift when pane=node for wrapped pi agent (mosaic yolo pi)', () => {
|
||
expect(detectDrift('pi', 'node')).toBe(false);
|
||
});
|
||
|
||
it('does NOT flag drift when pane=node for wrapped codex agent (mosaic yolo codex)', () => {
|
||
expect(detectDrift('codex', 'node')).toBe(false);
|
||
});
|
||
|
||
it('flags drift when pane=python3 for pi runtime (canary-pi dogfood regression guard)', () => {
|
||
expect(detectDrift('pi', 'python3')).toBe(true);
|
||
});
|
||
|
||
it('does NOT flag drift when pane=python3 for dogfood runtime', () => {
|
||
expect(detectDrift('dogfood', 'python3')).toBe(false);
|
||
});
|
||
|
||
it('flags drift for unknown pane command on known runtime', () => {
|
||
expect(detectDrift('claude', 'bash')).toBe(true);
|
||
});
|
||
|
||
it('RUNTIME_ACCEPTABLE_COMMANDS is exported and contains expected entries', () => {
|
||
expect(RUNTIME_ACCEPTABLE_COMMANDS['pi']).toContain('node');
|
||
expect(RUNTIME_ACCEPTABLE_COMMANDS['pi']).not.toContain('python3');
|
||
expect(RUNTIME_ACCEPTABLE_COMMANDS['dogfood']).toContain('python3');
|
||
expect(RUNTIME_ACCEPTABLE_COMMANDS['codex']).toContain('node');
|
||
});
|
||
});
|
||
|
||
describe('fleet install — auto-enable units for boot-survival', () => {
|
||
it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => {
|
||
expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([
|
||
'systemctl',
|
||
'--user',
|
||
'enable',
|
||
'mosaic-tmux-holder.service',
|
||
]);
|
||
expect(buildEnableLingerCommand('testuser')).toEqual(['loginctl', 'enable-linger', 'testuser']);
|
||
});
|
||
|
||
it('enables holder and each agent unit via injected runner after install', async () => {
|
||
const minimalRoster: FleetRoster = {
|
||
version: 1,
|
||
transport: 'tmux',
|
||
tmux: { socketName: 'mosaic-factory', holderSession: '_holder' },
|
||
defaults: { workingDirectory: '~/src' },
|
||
runtimes: { codex: { resetCommand: '/clear' } },
|
||
agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }],
|
||
};
|
||
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
await enableFleetUnits(runner, minimalRoster, {});
|
||
|
||
expect(calls).toContainEqual(['systemctl', '--user', 'enable', 'mosaic-tmux-holder.service']);
|
||
expect(calls).toContainEqual(['systemctl', '--user', 'enable', 'mosaic-agent@coder0.service']);
|
||
});
|
||
|
||
it('install still succeeds when systemctl enable returns non-zero (non-fatal)', async () => {
|
||
const minimalRoster: FleetRoster = {
|
||
version: 1,
|
||
transport: 'tmux',
|
||
tmux: { socketName: 'mosaic-factory', holderSession: '_holder' },
|
||
defaults: { workingDirectory: '~/src' },
|
||
runtimes: { codex: { resetCommand: '/clear' } },
|
||
agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }],
|
||
};
|
||
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
// Simulate systemctl enable failure
|
||
if (command === 'systemctl' && args.includes('enable')) {
|
||
return { stdout: '', stderr: 'Unit not found', exitCode: 1 };
|
||
}
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
// Must NOT reject/throw even when enable calls fail
|
||
await expect(enableFleetUnits(runner, minimalRoster, {})).resolves.toBeUndefined();
|
||
|
||
// The enable attempt must have been made
|
||
expect(calls.some((c) => c.includes('enable'))).toBe(true);
|
||
});
|
||
|
||
it('--no-enable skips all systemctl enable and loginctl linger calls', async () => {
|
||
const minimalRoster: FleetRoster = {
|
||
version: 1,
|
||
transport: 'tmux',
|
||
tmux: { socketName: 'mosaic-factory', holderSession: '_holder' },
|
||
defaults: { workingDirectory: '~/src' },
|
||
runtimes: { codex: { resetCommand: '/clear' } },
|
||
agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }],
|
||
};
|
||
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
await enableFleetUnits(runner, minimalRoster, { enable: false });
|
||
|
||
// No calls should include 'enable'
|
||
expect(calls.every((c) => !c.includes('enable'))).toBe(true);
|
||
// No loginctl calls at all
|
||
expect(calls.every((c) => c[0] !== 'loginctl')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('fleet ps — tenant and host', () => {
|
||
it('returns tenant_id and host as non-empty strings', () => {
|
||
const { tenant_id, host } = getDefaultTenantAndHost();
|
||
expect(typeof tenant_id).toBe('string');
|
||
expect(tenant_id.length).toBeGreaterThan(0);
|
||
expect(typeof host).toBe('string');
|
||
expect(host.length).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
describe('fleet ps — JSON output shape (FR-6)', () => {
|
||
it('produces --json records including tenant_id and host for each agent', async () => {
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
rosterPath,
|
||
[
|
||
'version: 1',
|
||
'transport: tmux',
|
||
'agents:',
|
||
' - name: canary-pi',
|
||
' runtime: pi',
|
||
' class: canary',
|
||
].join('\n'),
|
||
);
|
||
|
||
const nowMs = Date.now();
|
||
const activityEpoch = Math.floor((nowMs - 20_000) / 1000);
|
||
|
||
const runner: CommandRunner = async (command, args) => {
|
||
const fullArgs = [command, ...args].join(' ');
|
||
if (fullArgs.includes('systemctl') && fullArgs.includes('show')) {
|
||
return {
|
||
stdout: 'ActiveState=active\nSubState=running\nUnitFileState=disabled\n',
|
||
stderr: '',
|
||
exitCode: 0,
|
||
};
|
||
}
|
||
if (fullArgs.includes('list-panes')) {
|
||
return {
|
||
stdout: `12345 python3 0 ${activityEpoch}\n`,
|
||
stderr: '',
|
||
exitCode: 0,
|
||
};
|
||
}
|
||
if (fullArgs.includes('list-sessions')) {
|
||
// Only the roster agent session on the socket (no unmanaged sessions)
|
||
return { stdout: 'canary-pi\n', stderr: '', exitCode: 0 };
|
||
}
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const lines: string[] = [];
|
||
const origLog = console.log;
|
||
console.log = (msg: string) => {
|
||
lines.push(msg);
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']);
|
||
} finally {
|
||
console.log = origLog;
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
|
||
const json = JSON.parse(lines.join('')) as AgentPsRow[];
|
||
expect(Array.isArray(json)).toBe(true);
|
||
expect(json).toHaveLength(1);
|
||
|
||
const row = json[0]!;
|
||
// FR-6: tenant_id and host must be present
|
||
expect(typeof row.tenant_id).toBe('string');
|
||
expect(row.tenant_id.length).toBeGreaterThan(0);
|
||
expect(typeof row.host).toBe('string');
|
||
expect(row.host.length).toBeGreaterThan(0);
|
||
|
||
// drift: roster says pi, pane runs python3 → drift flag
|
||
expect(row.driftFlag).toBe(true);
|
||
// boot-enable warning: active + disabled
|
||
expect(row.bootEnableWarning).toBe(true);
|
||
|
||
// heartbeat missing → unknown
|
||
expect(row.heartbeat.health).toBe('unknown');
|
||
|
||
expect(row.name).toBe('canary-pi');
|
||
expect(row.runtime).toBe('pi');
|
||
expect(row.systemdActive).toBe('active');
|
||
expect(row.systemdEnabled).toBe('disabled');
|
||
|
||
// managed/source fields for roster agents
|
||
expect(row.managed).toBe(true);
|
||
expect(row.source).toBe('roster');
|
||
});
|
||
});
|
||
|
||
describe('fleet ps — command sequences issued', () => {
|
||
it('issues systemd show + tmux list-panes per agent, then list-sessions for socket discovery', async () => {
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
rosterPath,
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
if ([command, ...args].join(' ').includes('list-sessions')) {
|
||
// Only the roster agent — no unmanaged sessions
|
||
return { stdout: 'coder0\n', stderr: '', exitCode: 0 };
|
||
}
|
||
return {
|
||
stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=enabled\n',
|
||
stderr: '',
|
||
exitCode: 0,
|
||
};
|
||
};
|
||
|
||
// suppress console.log for table output
|
||
const origLog = console.log;
|
||
console.log = () => {};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
|
||
expect(calls).toEqual([
|
||
buildSystemdShowCommand('coder0'),
|
||
buildTmuxListPanesCommand('coder0', 'mosaic-factory'),
|
||
buildTmuxListSessionsCommand('mosaic-factory'),
|
||
]);
|
||
} finally {
|
||
console.log = origLog;
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('buildTmuxListSessionsCommand', () => {
|
||
it('builds exact list-sessions command with session_name format', () => {
|
||
expect(buildTmuxListSessionsCommand('mosaic-factory')).toEqual([
|
||
'tmux',
|
||
'-L',
|
||
'mosaic-factory',
|
||
'list-sessions',
|
||
'-F',
|
||
'#{session_name}',
|
||
]);
|
||
});
|
||
|
||
it('uses DEFAULT_SOCKET_NAME when socket is omitted', () => {
|
||
const cmd = buildTmuxListSessionsCommand();
|
||
expect(cmd[2]).toBe('mosaic-factory');
|
||
});
|
||
});
|
||
|
||
describe('parseTmuxListSessions', () => {
|
||
it('splits newline-delimited session names', () => {
|
||
expect(parseTmuxListSessions('canary-pi\n_holder\nsome-adhoc\n')).toEqual([
|
||
'canary-pi',
|
||
'_holder',
|
||
'some-adhoc',
|
||
]);
|
||
});
|
||
|
||
it('returns empty array for blank output', () => {
|
||
expect(parseTmuxListSessions('')).toEqual([]);
|
||
expect(parseTmuxListSessions(' \n \n')).toEqual([]);
|
||
});
|
||
|
||
it('trims whitespace from each line', () => {
|
||
expect(parseTmuxListSessions(' canary-pi \n some-adhoc \n')).toEqual([
|
||
'canary-pi',
|
||
'some-adhoc',
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('fleet ps — unmanaged socket sessions', () => {
|
||
it('includes unmanaged session row flagged UNMANAGED and excludes _holder', async () => {
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
rosterPath,
|
||
[
|
||
'version: 1',
|
||
'transport: tmux',
|
||
'agents:',
|
||
' - name: canary-pi',
|
||
' runtime: pi',
|
||
' class: canary',
|
||
].join('\n'),
|
||
);
|
||
|
||
const nowMs = Date.now();
|
||
const activityEpoch = Math.floor((nowMs - 10_000) / 1000);
|
||
|
||
const runner: CommandRunner = async (command, args) => {
|
||
const full = [command, ...args].join(' ');
|
||
if (full.includes('list-sessions')) {
|
||
// Socket has: canary-pi (roster), _holder (excluded), some-adhoc (unmanaged)
|
||
return { stdout: 'canary-pi\n_holder\nsome-adhoc\n', stderr: '', exitCode: 0 };
|
||
}
|
||
if (full.includes('list-panes')) {
|
||
return { stdout: `99999 bash 0 ${activityEpoch}\n`, stderr: '', exitCode: 0 };
|
||
}
|
||
if (full.includes('systemctl') && full.includes('show')) {
|
||
return {
|
||
stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=unknown\n',
|
||
stderr: '',
|
||
exitCode: 0,
|
||
};
|
||
}
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const lines: string[] = [];
|
||
const origLog = console.log;
|
||
console.log = (msg: string) => {
|
||
lines.push(msg);
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']);
|
||
} finally {
|
||
console.log = origLog;
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
|
||
const json = JSON.parse(lines.join('')) as AgentPsRow[];
|
||
expect(Array.isArray(json)).toBe(true);
|
||
|
||
// Should have 2 rows: canary-pi (roster) + some-adhoc (unmanaged); _holder excluded
|
||
expect(json).toHaveLength(2);
|
||
|
||
// Roster agent comes first
|
||
const rosterRow = json[0]!;
|
||
expect(rosterRow.name).toBe('canary-pi');
|
||
expect(rosterRow.managed).toBe(true);
|
||
expect(rosterRow.source).toBe('roster');
|
||
|
||
// Unmanaged session comes second
|
||
const unmanagedRow = json[1]!;
|
||
expect(unmanagedRow.name).toBe('some-adhoc');
|
||
expect(unmanagedRow.managed).toBe(false);
|
||
expect(unmanagedRow.source).toBe('socket');
|
||
expect(unmanagedRow.runtime).toBe('unknown');
|
||
|
||
// _holder must not appear
|
||
expect(json.map((r) => r.name)).not.toContain('_holder');
|
||
|
||
// tenant_id and host must be present on unmanaged rows
|
||
expect(typeof unmanagedRow.tenant_id).toBe('string');
|
||
expect(unmanagedRow.tenant_id.length).toBeGreaterThan(0);
|
||
expect(typeof unmanagedRow.host).toBe('string');
|
||
expect(unmanagedRow.host.length).toBeGreaterThan(0);
|
||
|
||
// driftFlag must be false for unmanaged (no roster runtime to compare)
|
||
expect(unmanagedRow.driftFlag).toBe(false);
|
||
});
|
||
|
||
it('shows UNMANAGED flag in table output for unmanaged sessions', async () => {
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
rosterPath,
|
||
[
|
||
'version: 1',
|
||
'transport: tmux',
|
||
'agents:',
|
||
' - name: canary-pi',
|
||
' runtime: pi',
|
||
' class: canary',
|
||
].join('\n'),
|
||
);
|
||
|
||
const runner: CommandRunner = async (command, args) => {
|
||
const full = [command, ...args].join(' ');
|
||
if (full.includes('list-sessions')) {
|
||
return { stdout: 'canary-pi\nsome-adhoc\n', stderr: '', exitCode: 0 };
|
||
}
|
||
if (full.includes('list-panes')) {
|
||
return { stdout: '0 bash 1 0\n', stderr: '', exitCode: 0 };
|
||
}
|
||
if (full.includes('systemctl') && full.includes('show')) {
|
||
return {
|
||
stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=unknown\n',
|
||
stderr: '',
|
||
exitCode: 0,
|
||
};
|
||
}
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const lines: string[] = [];
|
||
const origLog = console.log;
|
||
console.log = (msg: string) => {
|
||
lines.push(msg);
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
|
||
} finally {
|
||
console.log = origLog;
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
|
||
const tableOutput = lines.join('\n');
|
||
// some-adhoc row must appear with UNMANAGED flag
|
||
expect(tableOutput).toMatch(/some-adhoc/);
|
||
expect(tableOutput).toMatch(/UNMANAGED/);
|
||
// canary-pi roster row must not have UNMANAGED
|
||
const rosterLine = lines.find((l) => l.includes('canary-pi'));
|
||
expect(rosterLine).toBeDefined();
|
||
expect(rosterLine).not.toMatch(/UNMANAGED/);
|
||
});
|
||
|
||
it('gracefully shows only roster rows when list-sessions fails (socket missing)', async () => {
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
rosterPath,
|
||
[
|
||
'version: 1',
|
||
'transport: tmux',
|
||
'agents:',
|
||
' - name: canary-pi',
|
||
' runtime: pi',
|
||
' class: canary',
|
||
].join('\n'),
|
||
);
|
||
|
||
const runner: CommandRunner = async (command, args) => {
|
||
const full = [command, ...args].join(' ');
|
||
if (full.includes('list-sessions')) {
|
||
// Simulate socket missing
|
||
return { stdout: '', stderr: 'no server running on /tmp/...', exitCode: 1 };
|
||
}
|
||
if (full.includes('list-panes')) {
|
||
return { stdout: '12345 pi 0 0\n', stderr: '', exitCode: 0 };
|
||
}
|
||
if (full.includes('systemctl') && full.includes('show')) {
|
||
return {
|
||
stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=enabled\n',
|
||
stderr: '',
|
||
exitCode: 0,
|
||
};
|
||
}
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const lines: string[] = [];
|
||
const origLog = console.log;
|
||
console.log = (msg: string) => {
|
||
lines.push(msg);
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
// Must not throw
|
||
await expect(
|
||
program.parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
|
||
).resolves.toBeDefined();
|
||
} finally {
|
||
console.log = origLog;
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
|
||
const json = JSON.parse(lines.join('')) as AgentPsRow[];
|
||
// Only roster agent visible; no crash
|
||
expect(json).toHaveLength(1);
|
||
expect(json[0]!.name).toBe('canary-pi');
|
||
expect(json[0]!.managed).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('agent watch', () => {
|
||
it('builds exact grouped-viewer creation command', () => {
|
||
expect(
|
||
buildAgentWatchCreateViewerCommand('canary-pi', 'canary-pi-watch-123', 'mosaic-factory'),
|
||
).toEqual([
|
||
'tmux',
|
||
'-L',
|
||
'mosaic-factory',
|
||
'new-session',
|
||
'-d',
|
||
'-t',
|
||
'=canary-pi',
|
||
'-s',
|
||
'canary-pi-watch-123',
|
||
]);
|
||
});
|
||
|
||
it('builds exact viewer attach command (read-only)', () => {
|
||
expect(buildAgentWatchAttachCommand('canary-pi-watch-123', 'mosaic-factory')).toEqual([
|
||
'tmux',
|
||
'-L',
|
||
'mosaic-factory',
|
||
'attach',
|
||
'-r',
|
||
'-t',
|
||
'canary-pi-watch-123',
|
||
]);
|
||
});
|
||
|
||
it('builds exact viewer kill command', () => {
|
||
expect(buildAgentWatchKillViewerCommand('canary-pi-watch-123', 'mosaic-factory')).toEqual([
|
||
'tmux',
|
||
'-L',
|
||
'mosaic-factory',
|
||
'kill-session',
|
||
'-t',
|
||
'canary-pi-watch-123',
|
||
]);
|
||
});
|
||
|
||
it('buildAgentWatchCommand (deprecated) still uses DEFAULT_SOCKET_NAME when socket is omitted', () => {
|
||
const cmd = buildAgentWatchCommand('canary-pi');
|
||
expect(cmd[2]).toBe('mosaic-factory');
|
||
expect(cmd).toContain('-r');
|
||
});
|
||
|
||
it('dispatch: creates grouped viewer session (runner) then attaches -r to viewer session (interactiveRunner), NOT a bare attach to the agent session', async () => {
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
const capturingCalls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
capturingCalls.push([command, ...args]);
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const interactiveCalls: string[][] = [];
|
||
const interactiveRunner: InteractiveRunner = async (command, args) => {
|
||
interactiveCalls.push([command, ...args]);
|
||
return 0;
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, interactiveRunner, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync(['node', 'mosaic', 'agent', 'watch', 'coder0']);
|
||
|
||
// The capturing runner must be used for grouped-session creation and cleanup.
|
||
// It must NOT be used for the interactive attach.
|
||
expect(capturingCalls).toHaveLength(2); // new-session + kill-session
|
||
expect(capturingCalls[0]).toEqual(
|
||
expect.arrayContaining(['new-session', '-d', '-t', '=coder0']),
|
||
);
|
||
// The new-session command must include a viewer session name derived from agent name.
|
||
expect(capturingCalls[0]!.join(' ')).toMatch(/coder0-watch-\d+/);
|
||
// Kill-session must target the same viewer session, not the agent session.
|
||
expect(capturingCalls[1]).toEqual(expect.arrayContaining(['kill-session', '-t']));
|
||
expect(capturingCalls[1]!.join(' ')).toMatch(/coder0-watch-\d+/);
|
||
// The agent session itself must NOT be the attach target.
|
||
expect(capturingCalls[1]!.join(' ')).not.toContain('=coder0');
|
||
|
||
// The interactiveRunner must attach -r to the VIEWER session, not the agent session.
|
||
expect(interactiveCalls).toHaveLength(1);
|
||
expect(interactiveCalls[0]).toEqual(expect.arrayContaining(['attach', '-r', '-t']));
|
||
// Target must be the viewer session name (not "=coder0").
|
||
const attachTarget = interactiveCalls[0]![interactiveCalls[0]!.indexOf('-t') + 1]!;
|
||
expect(attachTarget).toMatch(/coder0-watch-\d+/);
|
||
expect(attachTarget).not.toBe('=coder0');
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('rejects watch for agents not in the roster', async () => {
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await expect(
|
||
program.parseAsync(['node', 'mosaic', 'agent', 'watch', 'typo']),
|
||
).rejects.toThrow('Agent "typo" is not in the fleet roster');
|
||
expect(runner).not.toHaveBeenCalled();
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('agent send --verify', () => {
|
||
it('builds exact verify capture-pane command', () => {
|
||
expect(buildAgentVerifyAcceptedCommand('canary-pi', 'mosaic-factory', 5)).toEqual([
|
||
'tmux',
|
||
'-L',
|
||
'mosaic-factory',
|
||
'capture-pane',
|
||
'-t',
|
||
'=canary-pi:0.0',
|
||
'-p',
|
||
'-S',
|
||
'-5',
|
||
]);
|
||
});
|
||
|
||
it('isSendAccepted: returns "accepted" for normal response output', () => {
|
||
expect(isSendAccepted('Some response text\nAnother line\n')).toBe('accepted');
|
||
});
|
||
|
||
it('isSendAccepted: returns "draft" when last line starts with "> " (draft pattern)', () => {
|
||
expect(isSendAccepted('> my unsent message')).toBe('draft');
|
||
});
|
||
|
||
it('isSendAccepted: returns "unverifiable" for blank/empty pane (full-screen TUI case)', () => {
|
||
expect(isSendAccepted('')).toBe('unverifiable');
|
||
expect(isSendAccepted(' \n \n')).toBe('unverifiable');
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// classifySendResult — BEFORE/AFTER pane-diff classifier (regression suite)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('classifySendResult (BEFORE/AFTER pane-diff classifier)', () => {
|
||
it('returns "accepted" when AFTER differs from BEFORE and AFTER has no draft line', () => {
|
||
const before = 'Old content from prior interaction\n';
|
||
const after = 'Old content from prior interaction\nAgent response: task complete.\n';
|
||
expect(classifySendResult(before, after)).toBe('accepted');
|
||
});
|
||
|
||
it('returns "draft" when AFTER differs from BEFORE and AFTER ends in a draft line', () => {
|
||
const before = 'Previous output\n';
|
||
const after = 'Previous output\n> unsent message\n';
|
||
expect(classifySendResult(before, after)).toBe('draft');
|
||
});
|
||
|
||
it('returns "unverifiable" when AFTER is blank/empty (full-screen TUI blank render)', () => {
|
||
const before = 'Some previous content\n';
|
||
expect(classifySendResult(before, '')).toBe('unverifiable');
|
||
expect(classifySendResult(before, ' \n \n')).toBe('unverifiable');
|
||
});
|
||
|
||
it('returns "unverifiable" when AFTER == BEFORE (stale/wedged pane — no change after send)', () => {
|
||
const staleContent = 'Old non-empty content that never changed\n';
|
||
expect(classifySendResult(staleContent, staleContent)).toBe('unverifiable');
|
||
});
|
||
|
||
it('returns "unverifiable" when both BEFORE and AFTER are blank (both blank => no change)', () => {
|
||
expect(classifySendResult('', '')).toBe('unverifiable');
|
||
});
|
||
|
||
it('returns "accepted" when BEFORE is blank and AFTER has non-draft content (pane woke up)', () => {
|
||
expect(classifySendResult('', 'Agent is now responding.\n')).toBe('accepted');
|
||
});
|
||
});
|
||
|
||
it('issues BEFORE-capture then send then AFTER-capture (3 calls) when --verify is passed and pane changes on first poll', async () => {
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
// no-op sleep so the test doesn't take VERIFY_DEFAULT_TIMEOUT_MS
|
||
const sleepFn: SleepFn = async () => {};
|
||
|
||
let callIndex = 0;
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
const idx = callIndex++;
|
||
if ([command, ...args].join(' ').includes('agent-send.sh')) {
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
}
|
||
// BEFORE capture (idx 0): return old content; first AFTER capture (idx 2): return new content
|
||
const stdout = idx === 0 ? 'Old pane content\n' : 'New response from agent\n';
|
||
return { stdout, stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'agent',
|
||
'send',
|
||
'coder0',
|
||
'--message',
|
||
'hello world',
|
||
'--verify',
|
||
]);
|
||
|
||
// 3 calls: BEFORE-capture, send, AFTER-capture (pane changed on first poll → accepted immediately)
|
||
expect(calls).toHaveLength(3);
|
||
expect(calls[0]).toEqual(buildAgentVerifyAcceptedCommand('coder0', 'mosaic-factory', 5));
|
||
expect(calls[1]![0]).toContain('agent-send.sh');
|
||
expect(calls[2]).toEqual(buildAgentVerifyAcceptedCommand('coder0', 'mosaic-factory', 5));
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
}, 10_000);
|
||
|
||
it('does NOT issue capture-pane verify when --verify is not passed', async () => {
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
const calls: string[][] = [];
|
||
const runner: CommandRunner = async (command, args) => {
|
||
calls.push([command, ...args]);
|
||
return { stdout: '', stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'agent',
|
||
'send',
|
||
'coder0',
|
||
'--message',
|
||
'hello world',
|
||
]);
|
||
// Only 1 call: agent-send.sh (no capture-pane)
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0]![0]).toContain('agent-send.sh');
|
||
} finally {
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('send --verify: AFTER==BEFORE (stale/wedged pane) sets process.exitCode=1 (unverifiable) after timeout', async () => {
|
||
const originalExitCode = process.exitCode;
|
||
const stderrMessages: string[] = [];
|
||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
|
||
stderrMessages.push(String(msg));
|
||
return true;
|
||
});
|
||
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
// Count sleep calls to verify polling happens; use no-op sleep for speed.
|
||
let sleepCalls = 0;
|
||
const sleepFn: SleepFn = async () => {
|
||
sleepCalls++;
|
||
};
|
||
|
||
const runner: CommandRunner = async (command, args) => {
|
||
const full = [command, ...args].join(' ');
|
||
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
|
||
// BEFORE and AFTER are identical non-empty stale content — simulates a wedged pane
|
||
return { stdout: 'Stale old content that never changed\n', stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });
|
||
|
||
try {
|
||
// Use a short verify-timeout (one poll interval worth) so the loop exits quickly.
|
||
// With a no-op sleep, Date.now() won't advance, so we only get 1 poll before
|
||
// deadline is exceeded. Use --verify-timeout=0 to force single-poll timeout.
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'agent',
|
||
'send',
|
||
'coder0',
|
||
'--message',
|
||
'hello',
|
||
'--verify',
|
||
'--verify-timeout',
|
||
'0',
|
||
]);
|
||
expect(process.exitCode).toBe(1);
|
||
// Must mention "no pane change" to distinguish from blank-capture case
|
||
expect(stderrMessages.join('')).toMatch(/no pane change after send/i);
|
||
// At least one poll should have happened
|
||
expect(sleepCalls).toBeGreaterThanOrEqual(1);
|
||
} finally {
|
||
process.exitCode = originalExitCode;
|
||
stderrSpy.mockRestore();
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
}, 10_000);
|
||
|
||
it('send --verify: blank AFTER capture sets process.exitCode=1 (unverifiable, fails closed) after timeout', async () => {
|
||
const originalExitCode = process.exitCode;
|
||
const stderrMessages: string[] = [];
|
||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
|
||
stderrMessages.push(String(msg));
|
||
return true;
|
||
});
|
||
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
const sleepFn: SleepFn = async () => {};
|
||
|
||
let captureCallCount = 0;
|
||
const runner: CommandRunner = async (command, args) => {
|
||
const full = [command, ...args].join(' ');
|
||
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
|
||
captureCallCount++;
|
||
// BEFORE: some content; AFTER: blank (full-screen TUI renders blank after send)
|
||
const stdout = captureCallCount === 1 ? 'Previous content\n' : '';
|
||
return { stdout, stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'agent',
|
||
'send',
|
||
'coder0',
|
||
'--message',
|
||
'hello',
|
||
'--verify',
|
||
'--verify-timeout',
|
||
'0',
|
||
]);
|
||
expect(process.exitCode).toBe(1);
|
||
expect(stderrMessages.join('')).toMatch(/could not verify delivery/i);
|
||
} finally {
|
||
process.exitCode = originalExitCode;
|
||
stderrSpy.mockRestore();
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
}, 10_000);
|
||
|
||
it('send --verify: AFTER differs from BEFORE with draft line sets process.exitCode=1 (returns immediately on first poll)', async () => {
|
||
const originalExitCode = process.exitCode;
|
||
const stderrMessages: string[] = [];
|
||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
|
||
stderrMessages.push(String(msg));
|
||
return true;
|
||
});
|
||
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
let sleepCalls = 0;
|
||
const sleepFn: SleepFn = async () => {
|
||
sleepCalls++;
|
||
};
|
||
|
||
let captureCallCount = 0;
|
||
const runner: CommandRunner = async (command, args) => {
|
||
const full = [command, ...args].join(' ');
|
||
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
|
||
captureCallCount++;
|
||
// BEFORE: old content; AFTER: message appeared but ended as a draft line
|
||
const stdout = captureCallCount === 1 ? 'Previous output\n' : '> unsent message\n';
|
||
return { stdout, stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'agent',
|
||
'send',
|
||
'coder0',
|
||
'--message',
|
||
'hello',
|
||
'--verify',
|
||
]);
|
||
expect(process.exitCode).toBe(1);
|
||
expect(stderrMessages.join('')).toMatch(/unsubmitted draft/i);
|
||
// Draft is returned on the first poll — only one sleep call expected
|
||
expect(sleepCalls).toBe(1);
|
||
} finally {
|
||
process.exitCode = originalExitCode;
|
||
stderrSpy.mockRestore();
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
}, 10_000);
|
||
|
||
it('send --verify: AFTER differs from BEFORE with real response content sets exitCode=0 (accepted on first poll)', async () => {
|
||
const originalExitCode = process.exitCode;
|
||
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
const sleepFn: SleepFn = async () => {};
|
||
|
||
let captureCallCount = 0;
|
||
const runner: CommandRunner = async (command, args) => {
|
||
const full = [command, ...args].join(' ');
|
||
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
|
||
captureCallCount++;
|
||
// BEFORE: old content; AFTER: new response content (pane changed)
|
||
const stdout =
|
||
captureCallCount === 1
|
||
? 'Old pane content\n'
|
||
: 'Old pane content\nAgent response: task completed.\n';
|
||
return { stdout, stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });
|
||
|
||
try {
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'agent',
|
||
'send',
|
||
'coder0',
|
||
'--message',
|
||
'hello',
|
||
'--verify',
|
||
]);
|
||
// exitCode should remain unchanged (not set to 1)
|
||
expect(process.exitCode).toBe(originalExitCode);
|
||
} finally {
|
||
process.exitCode = originalExitCode;
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
}, 10_000);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Bounded-polling tests (FR-5 enhancement)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it('send --verify: accepted on 2nd poll (pane slow to respond) => exit 0', async () => {
|
||
// Simulates a slow/loaded TUI that only updates on the 2nd poll.
|
||
const originalExitCode = process.exitCode;
|
||
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
let sleepCalls = 0;
|
||
const sleepFn: SleepFn = async (ms) => {
|
||
sleepCalls++;
|
||
expect(ms).toBe(VERIFY_POLL_INTERVAL_MS);
|
||
};
|
||
|
||
let captureCallCount = 0;
|
||
const runner: CommandRunner = async (command, args) => {
|
||
const full = [command, ...args].join(' ');
|
||
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
|
||
captureCallCount++;
|
||
if (captureCallCount === 1) {
|
||
// BEFORE: old content
|
||
return { stdout: 'Old pane content\n', stderr: '', exitCode: 0 };
|
||
} else if (captureCallCount === 2) {
|
||
// 1st AFTER poll: still unchanged (slow TUI) => unverifiable
|
||
return { stdout: 'Old pane content\n', stderr: '', exitCode: 0 };
|
||
} else {
|
||
// 2nd AFTER poll: pane changed => accepted
|
||
return { stdout: 'Old pane content\nAgent accepted task.\n', stderr: '', exitCode: 0 };
|
||
}
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });
|
||
|
||
try {
|
||
// Give enough timeout for at least 2 polls (2 × VERIFY_POLL_INTERVAL_MS).
|
||
// With no-op sleep, Date.now() will advance between polls so we use a generous timeout.
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'agent',
|
||
'send',
|
||
'coder0',
|
||
'--message',
|
||
'hello',
|
||
'--verify',
|
||
'--verify-timeout',
|
||
String(VERIFY_POLL_INTERVAL_MS * 3),
|
||
]);
|
||
// Accepted on 2nd poll — exitCode should remain unchanged
|
||
expect(process.exitCode).toBe(originalExitCode);
|
||
// At least 2 sleep calls (one per poll until accepted)
|
||
expect(sleepCalls).toBeGreaterThanOrEqual(2);
|
||
} finally {
|
||
process.exitCode = originalExitCode;
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
}, 10_000);
|
||
|
||
it('send --verify: accepted on 3rd poll => exit 0', async () => {
|
||
const originalExitCode = process.exitCode;
|
||
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
let sleepCalls = 0;
|
||
// Real-time advancing sleep needed so deadline check works correctly.
|
||
// Use a tiny delay (1ms) so the test runs fast but Date.now() still advances.
|
||
const sleepFn: SleepFn = async () => {
|
||
sleepCalls++;
|
||
await new Promise<void>((r) => setTimeout(r, 1));
|
||
};
|
||
|
||
let captureCallCount = 0;
|
||
const runner: CommandRunner = async (command, args) => {
|
||
const full = [command, ...args].join(' ');
|
||
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
|
||
captureCallCount++;
|
||
if (captureCallCount === 1) {
|
||
return { stdout: 'Old content\n', stderr: '', exitCode: 0 };
|
||
} else if (captureCallCount <= 3) {
|
||
// Polls 1 and 2: unchanged
|
||
return { stdout: 'Old content\n', stderr: '', exitCode: 0 };
|
||
} else {
|
||
// Poll 3: accepted
|
||
return { stdout: 'Old content\nDone!\n', stderr: '', exitCode: 0 };
|
||
}
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });
|
||
|
||
try {
|
||
// Long enough timeout to allow at least 3 polls with 1ms sleeps
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'agent',
|
||
'send',
|
||
'coder0',
|
||
'--message',
|
||
'hello',
|
||
'--verify',
|
||
'--verify-timeout',
|
||
'500',
|
||
]);
|
||
expect(process.exitCode).toBe(originalExitCode);
|
||
expect(sleepCalls).toBeGreaterThanOrEqual(3);
|
||
} finally {
|
||
process.exitCode = originalExitCode;
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
}, 10_000);
|
||
|
||
it('send --verify: pane stays unchanged until timeout => exit 1 (unverifiable)', async () => {
|
||
const originalExitCode = process.exitCode;
|
||
const stderrMessages: string[] = [];
|
||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
|
||
stderrMessages.push(String(msg));
|
||
return true;
|
||
});
|
||
|
||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||
await writeFile(
|
||
join(home, 'fleet', 'roster.yaml'),
|
||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||
'\n',
|
||
),
|
||
);
|
||
|
||
let sleepCalls = 0;
|
||
const sleepFn: SleepFn = async () => {
|
||
sleepCalls++;
|
||
};
|
||
|
||
const runner: CommandRunner = async (command, args) => {
|
||
const full = [command, ...args].join(' ');
|
||
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
|
||
// Always the same content — pane never changes
|
||
return { stdout: 'Unchanged pane content\n', stderr: '', exitCode: 0 };
|
||
};
|
||
|
||
const program = new Command();
|
||
program.exitOverride();
|
||
registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });
|
||
|
||
try {
|
||
// timeout=0 means deadline is immediately exceeded after the first poll
|
||
await program.parseAsync([
|
||
'node',
|
||
'mosaic',
|
||
'agent',
|
||
'send',
|
||
'coder0',
|
||
'--message',
|
||
'hello',
|
||
'--verify',
|
||
'--verify-timeout',
|
||
'0',
|
||
]);
|
||
expect(process.exitCode).toBe(1);
|
||
expect(stderrMessages.join('')).toMatch(/no pane change after send/i);
|
||
expect(sleepCalls).toBeGreaterThanOrEqual(1);
|
||
} finally {
|
||
process.exitCode = originalExitCode;
|
||
stderrSpy.mockRestore();
|
||
await rm(home, { recursive: true, force: true });
|
||
}
|
||
}, 10_000);
|
||
|
||
it('send --verify: VERIFY_POLL_INTERVAL_MS and VERIFY_DEFAULT_TIMEOUT_MS are exported constants', () => {
|
||
expect(typeof VERIFY_POLL_INTERVAL_MS).toBe('number');
|
||
expect(VERIFY_POLL_INTERVAL_MS).toBe(400);
|
||
expect(typeof VERIFY_DEFAULT_TIMEOUT_MS).toBe('number');
|
||
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`);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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;
|
||
|
||
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');
|
||
});
|
||
});
|
||
|
||
describe('fleet ps — heartbeat path resolution', () => {
|
||
const savedRunDir = process.env.MOSAIC_HEARTBEAT_RUN_DIR;
|
||
const savedHome = process.env.MOSAIC_HOME;
|
||
afterEach(() => {
|
||
if (savedRunDir === undefined) delete process.env.MOSAIC_HEARTBEAT_RUN_DIR;
|
||
else process.env.MOSAIC_HEARTBEAT_RUN_DIR = savedRunDir;
|
||
if (savedHome === undefined) delete process.env.MOSAIC_HOME;
|
||
else process.env.MOSAIC_HOME = savedHome;
|
||
});
|
||
|
||
it('honors MOSAIC_HEARTBEAT_RUN_DIR (matches the writer sidecar override)', () => {
|
||
process.env.MOSAIC_HEARTBEAT_RUN_DIR = '/run/hb';
|
||
expect(heartbeatPath('agent-x', '/any/home')).toBe(join('/run/hb', 'agent-x.hb'));
|
||
});
|
||
|
||
it('honors MOSAIC_HOME when no explicit mosaicHome is given', () => {
|
||
delete process.env.MOSAIC_HEARTBEAT_RUN_DIR;
|
||
process.env.MOSAIC_HOME = '/custom/mhome';
|
||
expect(heartbeatPath('agent-y')).toBe(join('/custom/mhome', 'fleet', 'run', 'agent-y.hb'));
|
||
});
|
||
|
||
it('falls back to <mosaicHome>/fleet/run by default', () => {
|
||
delete process.env.MOSAIC_HEARTBEAT_RUN_DIR;
|
||
delete process.env.MOSAIC_HOME;
|
||
expect(heartbeatPath('agent-z', '/home/u/.config/mosaic')).toBe(
|
||
join('/home/u/.config/mosaic', 'fleet', 'run', 'agent-z.hb'),
|
||
);
|
||
});
|
||
});
|