feat(fleet): reconcile local roster state (#785)
This commit was merged in pull request #785.
This commit is contained in:
257
packages/mosaic/src/commands/fleet-reconciler-command.spec.ts
Normal file
257
packages/mosaic/src/commands/fleet-reconciler-command.spec.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
||||
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||
|
||||
const roster = `
|
||||
version: 2
|
||||
generation: 7
|
||||
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
|
||||
`;
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function fleetHome(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-reconciler-command-'));
|
||||
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||
await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 });
|
||||
await chmod(join(cleanup, directory), 0o700);
|
||||
}
|
||||
await chmod(cleanup, 0o700);
|
||||
await writeFile(join(cleanup, 'fleet', 'roster.yaml'), roster, { mode: 0o600 });
|
||||
await writeFile(join(cleanup, 'fleet', 'roles', 'code.md'), '# code\n\n(`class: code`)\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
function program(
|
||||
mosaicHome: string,
|
||||
runner: FleetCommandDeps['runner'],
|
||||
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
||||
): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, {
|
||||
mosaicHome,
|
||||
runner,
|
||||
reconcileDeps: {
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||
validateRoster: async () => undefined,
|
||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||
applyProjection: async () => undefined,
|
||||
...reconcileOverrides,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function capture(): string[] {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||
lines.push(value);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function ownedRunner(
|
||||
calls: string[][],
|
||||
): (command: string, args: string[]) => Promise<CommandResult> {
|
||||
return async (command: string, args: string[]): Promise<CommandResult> => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
describe('mosaic fleet reconciler commands', (): void => {
|
||||
it('plans apply as stable JSON without applying projections or lifecycle effects', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'apply',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
});
|
||||
expect(calls).not.toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'start',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses direct fleet status and doctor as observational JSON commands', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
const calls: string[][] = [];
|
||||
const cli = program(home, ownedRunner(calls));
|
||||
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status', 'coder0']);
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'doctor']);
|
||||
|
||||
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
]);
|
||||
expect(
|
||||
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['start', 'stop', 'restart'] as const)(
|
||||
'uses exact roster-owned systemd targeting for %s',
|
||||
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
operation,
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(calls).toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
operation,
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
expect(calls.some((call: string[]): boolean => call.join(' ').includes('coder0-extra'))).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('makes cleanup-incomplete effect JSON non-zero without losing effect truth', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
await program(home, ownedRunner([]), {
|
||||
acquireMutationLock: async () => async () => {
|
||||
throw new Error('cleanup failure');
|
||||
},
|
||||
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||
applied: true,
|
||||
projections: 'complete',
|
||||
lifecycle: 'complete',
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps primary partial recovery JSON and exits non-zero when cleanup is also incomplete', async (): Promise<void> => {
|
||||
const cleanupFailure = async () => async () => {
|
||||
throw new Error('cleanup failure');
|
||||
};
|
||||
const projectionHome = await fleetHome();
|
||||
const projectionLines = capture();
|
||||
await program(projectionHome, ownedRunner([]), {
|
||||
applyProjection: async () => {
|
||||
throw new Error('projection failure');
|
||||
},
|
||||
acquireMutationLock: cleanupFailure,
|
||||
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
expect(JSON.parse(projectionLines.pop() ?? '')).toMatchObject({
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed' },
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps clean effect and observational commands at zero exit', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const cli = program(home, ownedRunner([]));
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
process.exitCode = undefined;
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects stale apply generations as non-zero redacted JSON', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner([])).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'apply',
|
||||
'--expected-generation',
|
||||
'6',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toEqual({ error: { code: 'stale-generation' } });
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
146
packages/mosaic/src/commands/fleet-reconciler-command.ts
Normal file
146
packages/mosaic/src/commands/fleet-reconciler-command.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import type { CommandRunner } from './fleet.js';
|
||||
import {
|
||||
executeFleetReconcile,
|
||||
FleetReconcileError,
|
||||
type FleetReconcileCommand,
|
||||
type FleetReconcileDeps,
|
||||
} from '../fleet/fleet-reconciler.js';
|
||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||
|
||||
export interface FleetReconcilerCommandDeps {
|
||||
readonly runner: CommandRunner;
|
||||
readonly mosaicHome?: string;
|
||||
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
||||
}
|
||||
|
||||
interface ReconcileOptions {
|
||||
readonly expectedGeneration?: string;
|
||||
readonly dryRun?: boolean;
|
||||
}
|
||||
|
||||
/** Registers roster-v2 reconciliation commands on the canonical fleet control plane. */
|
||||
export function registerFleetReconcilerCommands(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
): void {
|
||||
for (const operation of ['apply', 'reconcile'] as const) {
|
||||
fleetCommand
|
||||
.command(operation)
|
||||
.description(`${operation} local roster-owned projections and desired lifecycle`)
|
||||
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
|
||||
.option('--dry-run', 'Plan and preflight without writing projections or lifecycle state')
|
||||
.action(async (opts: ReconcileOptions): Promise<void> => {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, operation, opts);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fleetCommand
|
||||
.command('doctor')
|
||||
.description('Classify local roster-owned drift without mutation')
|
||||
.action(async (): Promise<void> => {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, 'doctor', {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeReconcilerCommandJson(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
operation: FleetReconcileCommand,
|
||||
opts: ReconcileOptions,
|
||||
agentName?: string,
|
||||
): Promise<void> {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, operation, opts, agentName);
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeReconcilerCommand(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
operation: FleetReconcileCommand,
|
||||
opts: ReconcileOptions,
|
||||
agentName?: string,
|
||||
): Promise<void> {
|
||||
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
||||
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
||||
const expectedGeneration = mutating
|
||||
? parseExpectedGeneration(opts.expectedGeneration)
|
||||
: undefined;
|
||||
const result = await executeFleetReconcile({
|
||||
roster,
|
||||
command: opts.dryRun === true ? 'plan' : operation,
|
||||
...(agentName === undefined ? {} : { agentName }),
|
||||
...(expectedGeneration === undefined ? {} : { expectedGeneration }),
|
||||
deps: {
|
||||
runner: async (command: string, args: readonly string[]) => deps.runner(command, [...args]),
|
||||
mosaicHome,
|
||||
rolesDir: join(mosaicHome, 'fleet', 'roles'),
|
||||
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
|
||||
readRoster: async (): Promise<typeof roster> =>
|
||||
parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'),
|
||||
...(deps.reconcileDeps ?? {}),
|
||||
},
|
||||
});
|
||||
printJson(result);
|
||||
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
|
||||
}
|
||||
|
||||
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
||||
return operation === 'start' || operation === 'stop' || operation === 'restart';
|
||||
}
|
||||
|
||||
function parseExpectedGeneration(value: string | undefined): number {
|
||||
const generation = Number(value);
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'--expected-generation must be a positive safe integer.',
|
||||
);
|
||||
}
|
||||
return generation;
|
||||
}
|
||||
|
||||
function resolveMosaicHome(fleetCommand: Command, deps: FleetReconcilerCommandDeps): string {
|
||||
const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>();
|
||||
return (
|
||||
options.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic')
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRosterPath(fleetCommand: Command, mosaicHome: string): string {
|
||||
const options = fleetCommand.optsWithGlobals<{ roster?: string }>();
|
||||
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Roster-v2 reconciliation requires the canonical roster path.',
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
async function writeOutcome(action: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
} catch (error: unknown) {
|
||||
process.exitCode = 1;
|
||||
printJson({
|
||||
error: {
|
||||
code: error instanceof FleetReconcileError ? error.code : 'reconcile-failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function printJson(value: object): void {
|
||||
console.log(JSON.stringify(value));
|
||||
}
|
||||
@@ -81,9 +81,11 @@ describe('registerFleetCommand', () => {
|
||||
expect(fleet).toBeDefined();
|
||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'add',
|
||||
'apply',
|
||||
'backlog',
|
||||
'create',
|
||||
'delete',
|
||||
'doctor',
|
||||
'get',
|
||||
'init',
|
||||
'install',
|
||||
@@ -93,6 +95,7 @@ describe('registerFleetCommand', () => {
|
||||
'profile',
|
||||
'provision',
|
||||
'ps',
|
||||
'reconcile',
|
||||
'remove',
|
||||
'restart',
|
||||
'start',
|
||||
@@ -490,7 +493,13 @@ describe('fleet command construction', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs fleet status through injected runner without touching tmux in tests', async () => {
|
||||
it('runs legacy fleet status through injected runner without touching tmux in tests', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
'version: 1\ntransport: tmux\nagents: []\n',
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
@@ -498,11 +507,14 @@ describe('fleet command construction', () => {
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner });
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
|
||||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
|
||||
@@ -618,13 +630,19 @@ describe('fleet command construction', () => {
|
||||
).rejects.toThrow('Unsupported fleet profile');
|
||||
});
|
||||
|
||||
it('sets process exitCode when status runner fails', async () => {
|
||||
it('sets process exitCode when legacy status runner fails', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
'version: 1\ntransport: tmux\nagents: []\n',
|
||||
);
|
||||
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 });
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
@@ -632,6 +650,7 @@ describe('fleet command construction', () => {
|
||||
} finally {
|
||||
process.exitCode = originalExitCode;
|
||||
stderrSpy.mockRestore();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
registerFleetAgentCrudCommands,
|
||||
type FleetAgentCrudCommandDeps,
|
||||
} from './fleet-agent-crud-command.js';
|
||||
import {
|
||||
executeReconcilerCommandJson,
|
||||
registerFleetReconcilerCommands,
|
||||
type FleetReconcilerCommandDeps,
|
||||
} from './fleet-reconciler-command.js';
|
||||
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
||||
import {
|
||||
applyPreparedAgentEnvironmentProjection,
|
||||
@@ -78,6 +83,7 @@ export interface FleetCommandDeps {
|
||||
*/
|
||||
isStdinTTY?: boolean;
|
||||
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
||||
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
||||
}
|
||||
|
||||
interface RawFleetRoster {
|
||||
@@ -1606,53 +1612,70 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
cmd
|
||||
.command(`${action} [agent]`)
|
||||
.description(`${action} the fleet holder or one agent`)
|
||||
.action(async (agent?: string) => {
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
if (agent) {
|
||||
getRosterAgent(roster, agent);
|
||||
// Single-agent restart is guarded too: it can race a full restart that
|
||||
// is tearing the shared holder down.
|
||||
.option('--expected-generation <number>', 'Authoritative roster generation for roster-v2')
|
||||
.option('--dry-run', 'Plan roster-v2 lifecycle effects without mutation')
|
||||
.action(
|
||||
async (
|
||||
agent: string | undefined,
|
||||
opts: { expectedGeneration?: string; dryRun?: boolean },
|
||||
) => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
action,
|
||||
opts,
|
||||
agent,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
if (agent) {
|
||||
getRosterAgent(roster, agent);
|
||||
// Single-agent restart is guarded too: it can race a full restart that
|
||||
// is tearing the shared holder down.
|
||||
if (action === 'restart') {
|
||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||
try {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
} finally {
|
||||
await guard.release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
return;
|
||||
}
|
||||
if (action === 'stop') {
|
||||
await stopFleetBestEffort(
|
||||
runner,
|
||||
roster.agents.map((rosterAgent) => rosterAgent.name),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action === 'restart') {
|
||||
// Serialize the holder+agents teardown/relaunch behind the restart lock
|
||||
// so a re-entrant restart waits for clean shutdown before relaunching,
|
||||
// instead of racing a half-torn-down holder into a tight loop.
|
||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||
try {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
} finally {
|
||||
await guard.release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
return;
|
||||
}
|
||||
if (action === 'stop') {
|
||||
await stopFleetBestEffort(
|
||||
runner,
|
||||
roster.agents.map((rosterAgent) => rosterAgent.name),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action === 'restart') {
|
||||
// Serialize the holder+agents teardown/relaunch behind the restart lock
|
||||
// so a re-entrant restart waits for clean shutdown before relaunching,
|
||||
// instead of racing a half-torn-down holder into a tight loop.
|
||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||
try {
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
} finally {
|
||||
await guard.release();
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cmd
|
||||
@@ -1660,6 +1683,16 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.description('Show fleet holder or agent systemd status')
|
||||
.option('--json', 'Print JSON status')
|
||||
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
'status',
|
||||
{},
|
||||
agent,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (agent) {
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
getRosterAgent(roster, agent);
|
||||
@@ -1683,6 +1716,15 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.command('verify')
|
||||
.description('Verify the local canary holder and roster sessions on the isolated socket')
|
||||
.action(async () => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
'verify',
|
||||
{},
|
||||
);
|
||||
return;
|
||||
}
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
const socketName = roster.tmux.socketName;
|
||||
await runChecked(runner, [
|
||||
@@ -2082,6 +2124,11 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
// Roster-v2 desired-state mutations belong directly to the fleet control
|
||||
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
||||
registerFleetAgentCrudCommands(cmd, deps);
|
||||
registerFleetReconcilerCommands(cmd, {
|
||||
runner,
|
||||
mosaicHome: deps.mosaicHome,
|
||||
reconcileDeps: deps.reconcileDeps,
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -2415,6 +2462,20 @@ async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||
}
|
||||
|
||||
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
||||
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
||||
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
||||
return (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed) &&
|
||||
'version' in parsed &&
|
||||
parsed.version === 2
|
||||
);
|
||||
}
|
||||
|
||||
async function loadRosterFromAgentCommand(
|
||||
command: Command,
|
||||
mosaicHomeOverride?: string,
|
||||
|
||||
Reference in New Issue
Block a user