Two follow-ups from the canary red->green run and scooby's review.
1. The v2 refusal in `add`/`remove` was a bare `throw`, which reaches the CLI
top level uncaught and prints the guidance under a Node stack trace. The
message *is* the point of the refusal, so it now goes through
`command.error()` — the same clean path the roster-config error uses.
Caught on canary, not in review: the unit tests asserted the message text
and passed either way.
2. The unit-template test asserted only that ConditionPathExists is present.
Presence is not effect. Added two tests for the parts that can drift in
code while that assertion still passes: the condition resolving to exactly
the file the fleet writes (%h/%i rendered against a real install), and the
launcher genuinely failing on an absent generated env (exit 64,
`missing-file`) — which is what makes the condition load-bearing rather
than decorative.
systemd is not available in the suite, so the effect itself was measured on
canary (2026-08-16), roster v2 generation 3:
with the condition: start rc=0, Result=success, ConditionResult=no,
journal "skipped, unmet condition check"
condition removed by
drop-in, nothing else: start rc=1, Result=exit-code, ExecMainStatus=64,
unit failed, "agent environment rejected: missing-file"
Canary red->green for the three commands, same v2 roster, side by side:
fleet ps 0.0.50-next.2413 rc=1 -> branch rc=0 (3 agents listed)
fleet install 0.0.50-next.2413 rc=1 -> branch rc=0
fleet remove <name> 0.0.50-next.2413 rc=1 -> branch rc=1, refusal naming
delete + apply
All three previously failed with "Fleet roster has unknown field(s):
generation." The #791 negative was measured too: the six existing
*.env.generated files were untouched by `install` (mtimes 20+ minutes older
than the run).
Gates: typecheck 0, eslint 0, prettier clean, fleet specs 382 passed, new spec
10/10 with the fix and 9/10 red against origin/next (the 10th passes there for
an unrelated reason and is annotated as such). Full suite: only
mutator-gate.acceptance.spec.ts fails, pre-existing on origin/next.
Still true and still worth saying: a correct fix here shows install rc=0 and
start rc=0 and STILL no live seat. #1240 (tmux absent) is upstream, #1241
(start reports lifecycle-complete over dead panes) and the missing agent
runtime are downstream.
Refs #1237
Reviewed-by: scooby (by git comms; cannot file a Gitea review from fomo-lin)
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import { Command } from 'commander';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
|
|
|
/**
|
|
* #1237: the v1-only commands (`ps`, `install`, `install-systemd`, `add`,
|
|
* `remove`) rejected a roster-v2 fleet outright, so a greenfield v2 box could
|
|
* never get its units placed. These tests pin the three behaviours that fix
|
|
* gives it, and the two it deliberately does NOT give it.
|
|
*
|
|
* The load-bearing negative is that `install` on v2 writes no generated env:
|
|
* the reconciler owns that file through projectRosterV2AgentGeneratedEnv, and a
|
|
* second writer here — necessarily through the v1 mapping — is exactly the
|
|
* drift the #791 single-SSOT invariant exists to prevent.
|
|
*/
|
|
|
|
const rosterV2 = `
|
|
version: 2
|
|
generation: 4
|
|
transport: tmux
|
|
tmux:
|
|
socket_name: mosaic-fleet
|
|
holder_session: _holder
|
|
defaults:
|
|
working_directory: /srv/mosaic
|
|
runtime: pi
|
|
runtimes:
|
|
pi:
|
|
reset_command: /new
|
|
agents:
|
|
- name: coder0
|
|
alias: Coder 0
|
|
class: code
|
|
runtime: pi
|
|
provider: openai
|
|
model: gpt-5.6-sol
|
|
reasoning: high
|
|
tool_policy: code
|
|
working_directory: /srv/mosaic
|
|
persistent_persona: false
|
|
reset_between_tasks: true
|
|
lifecycle:
|
|
enabled: true
|
|
desired_state: stopped
|
|
launch:
|
|
yolo: true
|
|
- name: coder1
|
|
alias: Coder 1
|
|
class: code
|
|
runtime: pi
|
|
provider: openai
|
|
model: gpt-5.6-sol
|
|
reasoning: medium
|
|
tool_policy: code
|
|
working_directory: /srv/other
|
|
persistent_persona: false
|
|
reset_between_tasks: true
|
|
lifecycle:
|
|
enabled: true
|
|
desired_state: stopped
|
|
launch:
|
|
yolo: true
|
|
`;
|
|
|
|
let tempHome: string | undefined;
|
|
const savedHome = process.env.HOME;
|
|
const savedMosaicHome = process.env.MOSAIC_HOME;
|
|
|
|
afterEach(async (): Promise<void> => {
|
|
vi.restoreAllMocks();
|
|
process.exitCode = undefined;
|
|
if (savedHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = savedHome;
|
|
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
|
else process.env.MOSAIC_HOME = savedMosaicHome;
|
|
if (tempHome) await rm(tempHome, { recursive: true, force: true });
|
|
tempHome = undefined;
|
|
});
|
|
|
|
/**
|
|
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
|
* anything has been installed, applied or started.
|
|
*/
|
|
async function v2Home(): Promise<string> {
|
|
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
|
process.env.HOME = tempHome;
|
|
delete process.env.MOSAIC_HOME;
|
|
const mosaicHome = join(tempHome, '.config', 'mosaic');
|
|
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
|
await mkdir(join(mosaicHome, directory), { recursive: true, mode: 0o700 });
|
|
}
|
|
await writeFile(join(mosaicHome, 'fleet', 'roster.yaml'), rosterV2, { mode: 0o600 });
|
|
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
|
mode: 0o600,
|
|
});
|
|
return mosaicHome;
|
|
}
|
|
|
|
/**
|
|
* Stands in for a box where nothing is running: every systemctl and tmux probe
|
|
* fails the way it does before the holder has ever started. `ps` must survive
|
|
* this — it is the command an operator reaches for to find out *why* there is
|
|
* no seat, so it has to report the emptiness rather than fail on it.
|
|
*/
|
|
const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult> => {
|
|
if (command === 'tmux') {
|
|
return { stdout: '', stderr: 'no server running on /tmp/tmux-1000/mosaic-fleet', exitCode: 1 };
|
|
}
|
|
return { stdout: '', stderr: '', exitCode: 1 };
|
|
};
|
|
|
|
function program(runner: CommandRunner = greenfieldRunner): Command {
|
|
const result = new Command();
|
|
result.exitOverride();
|
|
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
|
|
return result;
|
|
}
|
|
|
|
function capture(): string[] {
|
|
const lines: string[] = [];
|
|
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
|
lines.push(value);
|
|
});
|
|
return lines;
|
|
}
|
|
|
|
async function exists(path: string): Promise<boolean> {
|
|
try {
|
|
await stat(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
describe('mosaic fleet ps — roster v2', (): void => {
|
|
it('lists every v2 agent on a greenfield box with nothing running, and does not throw', async (): Promise<void> => {
|
|
await v2Home();
|
|
const lines = capture();
|
|
|
|
await expect(
|
|
program().parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
|
|
).resolves.toBeDefined();
|
|
|
|
const rows = JSON.parse(lines.join('\n')) as {
|
|
name: string;
|
|
runtime: string;
|
|
alias?: string;
|
|
paneAlive: boolean;
|
|
source: string;
|
|
}[];
|
|
expect(rows.map((row) => row.name).sort()).toEqual(['coder0', 'coder1']);
|
|
// The v2 roster's per-agent fields must survive the read model, not be
|
|
// flattened into defaults.
|
|
expect(rows.every((row) => row.runtime === 'pi')).toBe(true);
|
|
expect(rows.find((row) => row.name === 'coder0')?.alias).toBe('Coder 0');
|
|
// Nothing is running, and that is a report, not an error.
|
|
expect(rows.every((row) => row.paneAlive === false)).toBe(true);
|
|
expect(rows.every((row) => row.source === 'roster')).toBe(true);
|
|
expect(process.exitCode ?? 0).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('mosaic fleet install — roster v2', (): void => {
|
|
it('places the tool files and unit templates', async (): Promise<void> => {
|
|
const mosaicHome = await v2Home();
|
|
capture();
|
|
|
|
await expect(
|
|
program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
|
|
).resolves.toBeDefined();
|
|
|
|
// Units live in the systemd user dir, not under the Mosaic home.
|
|
const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
|
|
for (const unit of [
|
|
'mosaic-tmux-holder.service',
|
|
'[email protected]',
|
|
'[email protected]',
|
|
]) {
|
|
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
|
}
|
|
const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
|
|
expect(await exists(launcher)).toBe(true);
|
|
expect((await stat(launcher)).mode & 0o777).toBe(0o755);
|
|
});
|
|
|
|
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
|
const mosaicHome = await v2Home();
|
|
capture();
|
|
|
|
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
|
|
|
const agentDir = join(mosaicHome, 'fleet', 'agents');
|
|
expect(await readdir(agentDir)).toEqual([]);
|
|
});
|
|
|
|
it('tells the operator which command does own the env', async (): Promise<void> => {
|
|
await v2Home();
|
|
const lines = capture();
|
|
|
|
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
|
|
|
expect(lines.join('\n')).toContain('mosaic fleet apply');
|
|
});
|
|
});
|
|
|
|
describe('[email protected]', (): void => {
|
|
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
|
|
|
|
/** The single `ConditionPathExists=` value declared by the unit template. */
|
|
async function conditionPath(): Promise<string> {
|
|
const unit = await readFile(unitPath, 'utf8');
|
|
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
|
|
expect(matches).toHaveLength(1);
|
|
return matches[0]!.slice('ConditionPathExists='.length).trim();
|
|
}
|
|
|
|
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
|
|
// The pairing that makes "install writes no env" safe: install enables the
|
|
// unit (WantedBy=default.target) but does not start it, so without this
|
|
// condition a reboot between `install` and the first `apply` would run
|
|
// ExecStart against an absent env file and fail every seat unit.
|
|
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated');
|
|
});
|
|
|
|
/**
|
|
* The two halves of the guard's *effect*, which no assertion on the literal
|
|
* string can cover on its own.
|
|
*
|
|
* Measured end to end on a real box (canary, 2026-08-16) rather than inferred:
|
|
* with the condition, `systemctl --user start mosaic-agent@<name>` on an agent
|
|
* with no generated env returns rc=0, `Result=success`, `ConditionResult=no`,
|
|
* and journals "skipped, unmet condition check". With the condition removed by
|
|
* drop-in and nothing else changed, the same start returns rc=1,
|
|
* `Result=exit-code`, `ExecMainStatus=64`, and the unit enters `failed`.
|
|
*
|
|
* systemd is not available in this suite, so these two tests pin the parts
|
|
* that can drift in code: the condition naming a *different* file than the one
|
|
* the fleet actually writes, and the launcher quietly becoming tolerant of an
|
|
* absent env — either of which turns the condition into decoration while the
|
|
* literal-string assertion above still passes.
|
|
*/
|
|
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
|
|
const mosaicHome = await v2Home();
|
|
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0');
|
|
|
|
// The path an installed fleet actually places for this agent.
|
|
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'));
|
|
});
|
|
|
|
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
|
|
await v2Home();
|
|
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
|
|
|
// Exactly what ExecStart runs, against the state the condition exists to
|
|
// catch: unit enabled, reconciler has not written env yet.
|
|
const launched = await new Promise<{ code: number | null; stderr: string }>((settle) => {
|
|
const child = execFile(
|
|
'/bin/bash',
|
|
[
|
|
'--noprofile',
|
|
'--norc',
|
|
join(tempHome!, '.config', 'mosaic', 'tools', 'fleet', 'start-agent-session.sh'),
|
|
'coder0',
|
|
],
|
|
{ env: { HOME: tempHome!, MOSAIC_AGENT_NAME: 'coder0', PATH: '/usr/bin:/bin' } },
|
|
(_error, _stdout, stderr) => {
|
|
settle({ code: child.exitCode, stderr });
|
|
},
|
|
);
|
|
});
|
|
|
|
expect(launched.code).not.toBe(0);
|
|
expect(launched.stderr).toContain('missing-file');
|
|
});
|
|
});
|
|
|
|
describe('mosaic fleet add / remove — roster v2', (): void => {
|
|
it('add refuses, and names the two-step v2 sequence instead of inventing defaults', async (): Promise<void> => {
|
|
await v2Home();
|
|
|
|
await expect(
|
|
program().parseAsync([
|
|
'node',
|
|
'mosaic',
|
|
'fleet',
|
|
'add',
|
|
'coder2',
|
|
'--runtime',
|
|
'pi',
|
|
'--class',
|
|
'code',
|
|
]),
|
|
).rejects.toThrow(/mosaic fleet create[\s\S]*mosaic fleet apply/);
|
|
});
|
|
|
|
it('remove refuses, and names delete plus apply', async (): Promise<void> => {
|
|
await v2Home();
|
|
|
|
await expect(
|
|
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
|
|
).rejects.toThrow(/mosaic fleet delete coder1[\s\S]*mosaic fleet apply/);
|
|
});
|
|
|
|
// Note: this one passes on the unmodified tree too — there `remove` throws in
|
|
// the v1 parser, before it can touch anything. It is a regression guard on the
|
|
// ordering of the new guard clause, not evidence that the fix works.
|
|
it('refuses BEFORE mutating the roster', async (): Promise<void> => {
|
|
const mosaicHome = await v2Home();
|
|
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
|
const before = await readFile(rosterPath, 'utf8');
|
|
|
|
await expect(
|
|
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
|
|
).rejects.toThrow();
|
|
|
|
expect(await readFile(rosterPath, 'utf8')).toBe(before);
|
|
});
|
|
});
|