diff --git a/packages/mosaic/framework/systemd/user/mosaic-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-agent@.service index f4d4a985..81f76b54 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-agent@.service +++ b/packages/mosaic/framework/systemd/user/mosaic-agent@.service @@ -4,6 +4,14 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack Requires=mosaic-tmux-holder.service After=mosaic-tmux-holder.service PartOf=mosaic-tmux-holder.service +# Do not attempt a seat before its generated env exists. `install` enables this +# unit (WantedBy=default.target) but on a roster-v2 fleet the reconciler owns the +# generated env, so between `install` and the first `apply`/`regen --write` there +# is a boot window where ExecStart would run against an absent env file and the +# launcher would fail the unit. A skipped unit is the honest state for "enabled +# but not yet configured"; systemd re-evaluates the condition on every start, so +# the seat comes up on the next start once the reconciler has written env. +ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated [Service] Type=oneshot diff --git a/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts b/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts new file mode 100644 index 00000000..12d039a0 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts @@ -0,0 +1,323 @@ +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 => { + 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 { + 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 => { + 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 { + 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 => { + 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 => { + 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', + 'mosaic-agent@.service', + 'mosaic-interaction-agent@.service', + ]) { + 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 => { + 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 => { + await v2Home(); + const lines = capture(); + + await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); + + expect(lines.join('\n')).toContain('mosaic fleet apply'); + }); +}); + +describe('mosaic-agent@.service', (): void => { + const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', 'mosaic-agent@.service'); + + /** The single `ConditionPathExists=` value declared by the unit template. */ + async function conditionPath(): Promise { + 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 => { + // 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@` 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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); + }); +}); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index c344660a..b77c6e3a 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -34,6 +34,7 @@ export { resolveInstalledFleetRosterPath, } from '../fleet/fleet-roster-v1.js'; export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js'; +import { parseRosterV2 } from '../fleet/roster-v2.js'; import { registerFleetAgentCrudCommands, type FleetAgentCrudCommandDeps, @@ -820,7 +821,7 @@ export function buildEnableLingerCommand(user: string): string[] { */ export async function enableFleetUnits( runner: CommandRunner, - roster: FleetRoster, + roster: { readonly agents: readonly { readonly name: string }[] }, opts: { enable?: boolean }, ): Promise { if (opts.enable === false) { @@ -1527,7 +1528,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .option('--no-enable', 'Skip enabling units for boot-survival') .action(async (opts: { enable?: boolean }) => { await installFleet(cmd, frameworkRoot); - const roster = await loadRosterForCommand(cmd); + // Unit enablement needs agent names only, so it reads either version. + const roster = await loadRosterReadModel(cmd); await enableFleetUnits(runner, roster, opts); }); @@ -1537,7 +1539,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .option('--no-enable', 'Skip enabling units for boot-survival') .action(async (opts: { enable?: boolean }) => { await installFleet(cmd, frameworkRoot); - const roster = await loadRosterForCommand(cmd); + // Unit enablement needs agent names only, so it reads either version. + const roster = await loadRosterReadModel(cmd); await enableFleetUnits(runner, roster, opts); }); @@ -1688,7 +1691,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .action(async (opts: { json?: boolean }) => { const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const activePaths = resolveFleetPaths(commandOpts.mosaicHome); - const roster = await loadRosterForCommand(cmd); + // ps only reads, so it takes the version-agnostic read model rather than + // the v1 parser, which rejects a v2 roster outright. + const roster = await loadRosterReadModel(cmd); const { tenant_id, host } = getDefaultTenantAndHost(); const nowMs = Date.now(); @@ -1908,6 +1913,16 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = start: boolean; }, ) => { + if (await usesRosterV2ControlPlane(cmd)) { + // command.error, not a bare throw: this is operator guidance, and a + // bare throw reaches the top level uncaught and prints it under a Node + // stack trace. Measured on canary — the message is the whole point of + // the refusal, so it has to arrive readable. + cmd.error(rosterV2MutationGuidance('add', 'create', name), { + code: 'fleet.roster-v2', + exitCode: 1, + }); + } if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) { throw new Error( `Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`, @@ -1973,6 +1988,12 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .description('Remove an agent from the fleet roster') .option('--keep-files', 'Skip deleting env and heartbeat files') .action(async (name: string, opts: { keepFiles?: boolean }) => { + if (await usesRosterV2ControlPlane(cmd)) { + cmd.error(rosterV2MutationGuidance('remove', 'delete', name), { + code: 'fleet.roster-v2', + exitCode: 1, + }); + } const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const activePaths = resolveFleetPaths(commandOpts.mosaicHome); const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster); @@ -2331,7 +2352,9 @@ export function registerFleetAgentCommands( async function installFleet(cmd: Command, frameworkRoot: string): Promise { const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome); assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome); - const roster = await loadRosterForCommand(cmd); + // Read model first: every file this function places is roster-independent, and + // the v1 parser would reject a v2 roster before any of them were written. + const roster = await loadRosterReadModel(cmd); await ensureFleetHolderIdentity(activePaths.mosaicHome); await mkdir(activePaths.fleetToolsDir, { recursive: true }); await mkdir(activePaths.tmuxToolsDir, { recursive: true }); @@ -2391,16 +2414,30 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise join(activePaths.systemdUserDir, 'mosaic-interaction-agent@.service'), ); - for (const agent of roster.agents) { + // On roster v2 the reconciler owns the generated env: `apply` writes it and + // `regen` rebuilds it, both from projectRosterV2AgentGeneratedEnv. Writing it + // here too — necessarily through the v1 mapping — would be the third writer of + // one file and would break the #791 single-SSOT invariant. So v2 gets the tool + // files and the units, and nothing else. + if (roster.version === 2) { + console.log( + `Installed fleet tools and systemd units for ${roster.agents.length} agent(s). ` + + `Generated env is owned by the reconciler on roster v2 — run: mosaic fleet apply --expected-generation `, + ); + return; + } + + const v1Roster = await loadRosterForCommand(cmd); + for (const agent of v1Roster.agents) { await writeAgentEnvironmentProjection({ mosaicHome: activePaths.mosaicHome, agentEnvDir: activePaths.agentEnvDir, agentName: agent.name, - generated: generateAgentEnvValues(roster, agent), + generated: generateAgentEnvValues(v1Roster, agent), }); } - console.log(`Installed fleet files for ${roster.agents.length} agent(s).`); + console.log(`Installed fleet files for ${v1Roster.agents.length} agent(s).`); } async function loadRosterForCommand(cmd: Command): Promise { @@ -2427,6 +2464,77 @@ async function usesRosterV2ControlPlane(cmd: Command): Promise { ); } +/** + * `add`/`remove` and `create`/`delete` are not two spellings of one operation. + * The v1 pair edits the roster *and* drives systemd; the v2 pair is documented + * as changing desired state "without runtime actions", leaving convergence to + * `apply`. `add` also collects four fields where a v2 agent requires eleven, so + * routing it to `create` would mean inventing provider, alias, reasoning and + * tool-policy defaults on the operator's behalf. Refusing with the real command + * is honest; silently guessing an agent's provider is not. + */ +function rosterV2MutationGuidance( + v1Command: 'add' | 'remove', + v2Command: 'create' | 'delete', + name: string, +): string { + const target = v2Command === 'delete' ? ` ${name}` : ''; + return ( + `mosaic fleet ${v1Command} does not operate on a roster-v2 fleet. ` + + `Roster v2 separates desired state from convergence:\n` + + ` 1. mosaic fleet ${v2Command}${target} --expected-generation ` + + `${v2Command === 'create' ? "--agent '' " : ''}` + + `(edits the roster only)\n` + + ` 2. mosaic fleet apply --expected-generation (converges systemd and tmux)\n` + + `Read the current generation with: mosaic fleet status` + ); +} + +/** + * The read-only fields shared by roster v1 and v2, for the commands that only + * ever *read* the roster (`ps`, and unit enablement inside `install`). + * + * This is deliberately NOT a v2→v1 downshift. A downshifted `FleetRoster` would + * be accepted by `generateAgentEnvValues`, and that would make a third writer of + * `fleet/agents/.env.generated` — through the v1 mapping — breaking the + * #791 single-SSOT invariant that {@link projectRosterV2AgentGeneratedEnv} is + * documented to hold. Keeping the read model this small makes that misuse + * impossible: there is nothing here to write a roster or an env file back from. + */ +interface FleetRosterReadModel { + readonly version: 1 | 2; + readonly tmux: { readonly socketName: string; readonly holderSession: string }; + readonly agents: readonly { + readonly name: string; + readonly alias?: string; + readonly runtime: string; + }[]; +} + +/** Reads either roster version into the shared read-only view. */ +async function loadRosterReadModel(cmd: Command): Promise { + const opts = cmd.opts<{ mosaicHome: string; roster?: string }>(); + const path = await resolveRosterPath(opts.mosaicHome, opts.roster); + if (!(await usesRosterV2ControlPlane(cmd))) { + const v1 = await loadRosterAtPath(cmd, path); + return { + version: 1, + tmux: { socketName: v1.tmux.socketName, holderSession: v1.tmux.holderSession }, + agents: v1.agents, + }; + } + try { + const v2 = parseRosterV2(await readFleetRosterText(path), 'yaml'); + return { + version: 2, + tmux: { socketName: v2.tmux.socketName, holderSession: v2.tmux.holderSession }, + agents: v2.agents, + }; + } catch (error) { + reportFleetRosterConfigurationError(cmd, error); + } +} + async function loadRosterFromAgentCommand( command: Command, mosaicHomeOverride?: string,