Compare commits

..

1 Commits

Author SHA1 Message Date
dcb7477007 feat(mosaic): mosaic update re-seeds framework + relaunches agents (#609)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Closes R13 (F3-m3). mosaic update installed the new npm CLI but never
re-seeded ~/.config/mosaic from the package's bundled framework/, so shipped
launcher/runtime changes (agent-name export + native HB) stayed DORMANT until
a manual re-seed — operators got the new CLI on a stale framework.

- update-checker.ts: resolveBundledFrameworkRoot, buildReseedCommand (install.sh
  in MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep — the P4 data-safe reconcile:
  framework-owned overwrite + backup-once; SOUL/USER/*.local/credentials kept),
  runFrameworkReseed, readRosterAgentNames, buildRelaunchCommands.
- cli.ts update: after a successful CLI install that includes @mosaicstack/mosaic,
  re-seed the framework (default-on; --no-reseed to skip). Then --relaunch restarts
  rostered agents (systemctl --user restart mosaic-agent@<name>), else prints clear
  activation guidance. Only re-seeds when the framework-bearing package updated.

Flow: update CLI -> re-seed framework (data-safe) -> relaunch agents (opt-in).

Verified: 6 new unit tests + 19 runtime + 26 launch tests green; tsc/eslint/
prettier clean. Sync data-safety already proven (P4 matrix + live validation).

Refs #609

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsgTQzV5YUGk1JtCLP4B83
2026-06-21 22:29:03 -05:00
4 changed files with 6 additions and 203 deletions

View File

@@ -58,7 +58,3 @@ Active workstream is **W1 — Federation v1**. Workers should:
## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed ## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed
- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md. - Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md.
## Fleet-polish bundle — boot-survival symmetry (#611) — feat/fleet-polish-bundle
- Status: implemented + tested. disable-on-remove (boot-resurrection bug, TDD) + add-enable + init-R5 hard guarantee. 4 new + 147 existing fleet tests green. Detail: scratchpads/fleet-polish-bundle.md.

View File

@@ -1,20 +0,0 @@
# Fleet-polish bundle — boot-survival symmetry (#611)
- **Issue:** #611 · **Branch:** `feat/fleet-polish-bundle` · From the Lead's Codex symmetry-gap finding.
## Three fixes
1. **disable-on-remove (BUG, TDD).** `fleet remove` stopped + deleted roster/env/heartbeat but never
`systemctl --user disable mosaic-agent@NAME.service` → a removed-but-enabled unit could resurrect on
reboot pointing at deleted config. Fix: `buildSystemdDisableCommand` + disable in `remove`
(best-effort, gated on !--keep-files).
2. **add-enable.** `fleet add` now enables the new agent's unit for boot-survival (best-effort,
independent of --start) — symmetry with disable-on-remove.
3. **init-R5 guarantee.** `fleet init --write` now FAILS HARD when a non-minimal profile doesn't yield
exactly one orchestrator (was a soft warning). `minimal` (sanctioned no-orchestrator) still allowed.
## Verification
- 4 new tests (disable builder; remove-invokes-disable; add-invokes-enable; init general → exactly 1
orchestrator) + 147 existing fleet tests green (151 total). tsc/eslint/prettier clean.
- TDD on the disable bug per contract.

View File

@@ -14,7 +14,6 @@ import {
buildEnableLingerCommand, buildEnableLingerCommand,
buildFleetServiceCommand, buildFleetServiceCommand,
buildSystemdEnableCommand, buildSystemdEnableCommand,
buildSystemdDisableCommand,
buildSystemdShowCommand, buildSystemdShowCommand,
buildTmuxListPanesCommand, buildTmuxListPanesCommand,
buildTmuxListSessionsCommand, buildTmuxListSessionsCommand,
@@ -984,127 +983,6 @@ describe('fleet ps — drift detection', () => {
}); });
}); });
describe('fleet-polish bundle — boot-survival symmetry', () => {
async function rosterHome(agents: string): Promise<string> {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(join(home, 'fleet', 'roster.yaml'), agents);
return home;
}
it('buildSystemdDisableCommand returns the systemctl --user disable array', () => {
expect(buildSystemdDisableCommand('mosaic-agent@coder0.service')).toEqual([
'systemctl',
'--user',
'disable',
'mosaic-agent@coder0.service',
]);
});
it('fleet remove DISABLES the unit so a removed agent cannot resurrect on boot', async () => {
const home = await rosterHome(
[
'version: 1',
'transport: tmux',
'agents:',
' - name: orchestrator',
' runtime: pi',
' class: orchestrator',
' - name: coder0',
' runtime: codex',
' class: worker',
].join('\n') + '\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', 'remove', 'coder0']);
expect(calls).toContainEqual([
'systemctl',
'--user',
'disable',
'mosaic-agent@coder0.service',
]);
// stop must still happen too
expect(calls).toContainEqual(['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service']);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('fleet add ENABLES the new agent unit for boot-survival', async () => {
const home = await rosterHome(
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
) + '\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',
'add',
'coder1',
'--runtime',
'codex',
'--class',
'worker',
'--no-start',
]);
expect(calls).toContainEqual([
'systemctl',
'--user',
'enable',
'mosaic-agent@coder1.service',
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('fleet init --write fails hard when a non-minimal profile lacks exactly one orchestrator', async () => {
// The general profile must yield exactly one orchestrator; the guarantee is
// enforced (not just warned). We assert the happy path writes cleanly.
const home = await tempDir();
const program = new Command();
program.exitOverride();
registerFleetCommand(program, {
runner: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
mosaicHome: home,
});
try {
await program.parseAsync([
'node',
'mosaic',
'fleet',
'init',
'--profile',
'general',
'--write',
]);
const written = await readFile(join(home, 'fleet', 'roster.yaml'), 'utf8');
const orchestrators = (written.match(/class:\s*orchestrator/g) ?? []).length;
expect(orchestrators).toBe(1);
} finally {
await rm(home, { recursive: true, force: true });
}
});
});
describe('fleet install — auto-enable units for boot-survival', () => { describe('fleet install — auto-enable units for boot-survival', () => {
it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => { it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => {
expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([ expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([

View File

@@ -227,15 +227,6 @@ export function buildSystemdEnableCommand(unit: string): string[] {
return ['systemctl', '--user', 'enable', unit]; return ['systemctl', '--user', 'enable', unit];
} }
/**
* Returns the systemctl --user disable command for a given unit.
* Used by `fleet remove` so a removed agent's enabled unit cannot resurrect on
* boot pointing at deleted config (boot-survival symmetry with enable-on-add).
*/
export function buildSystemdDisableCommand(unit: string): string[] {
return ['systemctl', '--user', 'disable', unit];
}
/** /**
* Returns the loginctl enable-linger command for a given user. * Returns the loginctl enable-linger command for a given user.
* Linger allows user systemd services to survive logout. * Linger allows user systemd services to survive logout.
@@ -881,19 +872,15 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
await mkdir(dirname(destination), { recursive: true }); await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, content); await writeFile(destination, content);
// Guarantee R5: exactly one orchestrator for every profile except the // Validate: exactly one orchestrator required (R5) — friendly summary on success.
// sanctioned no-orchestrator `minimal` preset. A mismatch means a
// corrupted/edited preset — fail hard rather than write a malformed fleet.
const written = await loadFleetRoster(destination); const written = await loadFleetRoster(destination);
const orchCount = countOrchestrators(written); const orchCount = countOrchestrators(written);
if (profile === 'minimal') { if (orchCount !== 1) {
console.log( process.stderr.write(
`Initialized ${profile} fleet: ${written.agents.length} agent(s) (no orchestrator). Next: mosaic fleet install`, `Warning: fleet roster at ${destination} has ${orchCount} orchestrator agent(s) (expected exactly 1).\n`,
); );
} else if (orchCount !== 1) { console.log(
throw new Error( `Initialized ${profile} fleet: ${written.agents.length} agent(s). Next: mosaic fleet install`,
`Fleet init failed: the "${profile}" roster has ${orchCount} orchestrator agent(s), ` +
`expected exactly 1 (R5). The preset may be corrupted — re-install the framework.`,
); );
} else { } else {
const workerCount = written.agents.length - 1; const workerCount = written.agents.length - 1;
@@ -1231,24 +1218,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`); console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
// Enable the unit for boot-survival (non-fatal) — symmetry with
// disable-on-remove. Independent of --start so a queued agent still
// survives a reboot once its unit exists.
try {
const enableResult = await runner(
...splitCommand(buildSystemdEnableCommand(`mosaic-agent@${name}.service`)),
);
if (enableResult.exitCode !== 0) {
process.stderr.write(
`Warning: could not enable mosaic-agent@${name}.service: ${enableResult.stderr || enableResult.stdout || 'non-zero exit'}\n`,
);
}
} catch (err) {
process.stderr.write(
`Warning: enable command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
if (opts.start !== false) { if (opts.start !== false) {
await runChecked(runner, buildFleetServiceCommand('start', name)); await runChecked(runner, buildFleetServiceCommand('start', name));
console.log(`Started mosaic-agent@${name}.service.`); console.log(`Started mosaic-agent@${name}.service.`);
@@ -1285,26 +1254,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
); );
} }
// Disable the unit (non-fatal) so an enabled instance cannot resurrect on
// boot pointing at the now-deleted config — boot-survival symmetry with
// enable-on-add. Skipped only when --keep-files keeps the config in place.
if (!opts.keepFiles) {
try {
const disableResult = await runner(
...splitCommand(buildSystemdDisableCommand(`mosaic-agent@${name}.service`)),
);
if (disableResult.exitCode !== 0) {
process.stderr.write(
`Warning: could not disable mosaic-agent@${name}.service: ${disableResult.stderr || disableResult.stdout || 'non-zero exit'}\n`,
);
}
} catch (err) {
process.stderr.write(
`Warning: disable command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
// Write updated roster // Write updated roster
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster)); await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));