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
This commit is contained in:
2026-06-21 22:29:03 -05:00
parent 528700ceea
commit dcb7477007
5 changed files with 267 additions and 2 deletions

View File

@@ -26,6 +26,10 @@ import {
checkForAllUpdates,
formatAllPackagesTable,
getInstallAllCommand,
runFrameworkReseed,
readRosterAgentNames,
buildRelaunchCommands,
FRAMEWORK_RESEED_PACKAGE,
} from './runtime/update-checker.js';
import { runWizard } from './wizard.js';
import { ClackPrompter } from './prompter/clack-prompter.js';
@@ -404,7 +408,12 @@ program
.command('update')
.description('Check for and install Mosaic CLI updates')
.option('--check', 'Check only, do not install')
.action(async (opts: { check?: boolean }) => {
.option(
'--no-reseed',
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
)
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
.action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
// checkForAllUpdates imported statically above
const { execSync } = await import('node:child_process');
@@ -442,6 +451,51 @@ program
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
process.exit(1);
}
// F3-m3 / R13: the CLI is updated, but the framework files in
// ~/.config/mosaic/ are still the previous version. Re-seed them from the
// freshly-installed package so shipped launcher/runtime changes ACTIVATE.
// Only when the framework-bearing package itself updated.
const mosaicUpdated = outdated.some(
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
);
if (mosaicUpdated && opts.reseed !== false) {
console.log(
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
);
const reseed = runFrameworkReseed();
if (reseed.ok) {
console.log('✔ Framework re-seeded.');
const agents = readRosterAgentNames();
if (agents.length > 0) {
if (opts.relaunch) {
console.log(
`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`,
);
for (const restart of buildRelaunchCommands(agents)) {
try {
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
} catch {
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
}
}
console.log('✔ Agents relaunched.');
} else {
console.log(
`\n ${agents.length} fleet agent(s) are still running the previous runtime. ` +
'Restart them to activate the update:\n mosaic update --relaunch ' +
'(or: mosaic fleet restart <agent>)',
);
}
}
} else {
console.error(
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
);
}
}
});
// ─── wizard ─────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
buildReseedCommand,
buildRelaunchCommands,
readRosterAgentNames,
runFrameworkReseed,
} from './update-checker.js';
/**
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
* durable agents so shipped launcher/runtime changes activate. These cover the
* pure builders + the missing-installer guard (the exec path is integration).
*/
describe('buildReseedCommand', () => {
it('invokes the package install.sh in data-safe sync-only keep mode', () => {
const out = buildReseedCommand('/pkg/framework', '/home/u/.config/mosaic');
expect(out.installer).toBe('/pkg/framework/install.sh');
expect(out.command).toBe('bash /pkg/framework/install.sh');
expect(out.env).toEqual({
MOSAIC_SYNC_ONLY: '1',
MOSAIC_INSTALL_MODE: 'keep',
MOSAIC_HOME: '/home/u/.config/mosaic',
});
});
});
describe('buildRelaunchCommands', () => {
it('builds a systemctl --user restart per agent unit', () => {
expect(buildRelaunchCommands(['orchestrator', 'coder0'])).toEqual([
['systemctl', '--user', 'restart', 'mosaic-agent@orchestrator.service'],
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
]);
});
it('is empty for an empty roster', () => {
expect(buildRelaunchCommands([])).toEqual([]);
});
});
describe('readRosterAgentNames', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'mosaic-roster-'));
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
it('returns [] when no roster exists', () => {
expect(readRosterAgentNames(home)).toEqual([]);
});
it('extracts agent names from roster.yaml', () => {
mkdirSync(join(home, 'fleet'), { recursive: true });
writeFileSync(
join(home, 'fleet', 'roster.yaml'),
[
'version: 1',
'agents:',
' - name: orchestrator',
' runtime: pi',
' - name: coder0',
' runtime: claude',
' - name: "reviewer-1"',
' runtime: codex',
].join('\n') + '\n',
);
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
});
});
describe('runFrameworkReseed', () => {
it('reports not-ok (not throw) when the installer is absent', () => {
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
const res = runFrameworkReseed(missing, join(missing, 'home'));
expect(res.ok).toBe(false);
expect(res.reason).toContain('installer not found');
rmSync(missing, { recursive: true, force: true });
});
});

View File

@@ -16,7 +16,8 @@
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -453,6 +454,98 @@ export function getInstallAllCommand(outdated: PackageUpdateResult[]): string {
return `npm i -g ${pkgs.join(' ')}`;
}
// ─── Post-update framework re-seed + agent relaunch (F3-m3 / R13) ─────────────
//
// `mosaic update` installs the new npm CLI but, on its own, leaves the framework
// files in ~/.config/mosaic/ stale — so shipped launcher/runtime changes (e.g.
// the agent-name export + native heartbeat) never ACTIVATE until a re-seed.
// These helpers run the package's own install.sh in sync-only mode (the P4
// data-safe reconcile: framework-owned overwrite + backup-once; SOUL/USER/
// *.local/credentials preserved) and, opt-in, relaunch durable agents.
/** Resolve the framework/ directory bundled in the installed package. */
export function resolveBundledFrameworkRoot(): string {
// dist/runtime/update-checker.js → ../../framework (package files: dist + framework)
return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'framework');
}
export const FRAMEWORK_RESEED_PACKAGE = PKG;
/**
* Build the framework re-seed invocation: the package's install.sh in
* sync-only mode (file phase only — no environment-touching post-install),
* keep mode (never overwrite user files). Returned as data so it is unit
* testable; `runFrameworkReseed` executes it.
*/
export function buildReseedCommand(
frameworkRoot: string,
mosaicHome: string,
): { installer: string; command: string; env: Record<string, string> } {
const installer = join(frameworkRoot, 'install.sh');
return {
installer,
command: `bash ${installer}`,
env: {
MOSAIC_SYNC_ONLY: '1',
MOSAIC_INSTALL_MODE: 'keep',
MOSAIC_HOME: mosaicHome,
},
};
}
/**
* Re-seed the framework from the freshly-installed package. Returns a result
* describing what happened (so callers can message + decide on relaunch).
* Best-effort: a missing installer or a non-zero exit is reported, not thrown.
*/
export function runFrameworkReseed(
frameworkRoot = resolveBundledFrameworkRoot(),
mosaicHome = join(homedir(), '.config', 'mosaic'),
): { ok: boolean; reason?: string } {
const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome);
if (!existsSync(installer)) {
return { ok: false, reason: `installer not found: ${installer}` };
}
try {
execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 });
return { ok: true };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}
}
/**
* Best-effort parse of the fleet roster for agent names (used to relaunch
* durable agents after a re-seed). Returns [] when no roster exists.
*/
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
if (!existsSync(rosterPath)) return [];
let text: string;
try {
text = readFileSync(rosterPath, 'utf-8');
} catch {
return [];
}
// Roster agents are listed as `- name: <id>` entries under `agents:`.
const names: string[] = [];
for (const line of text.split('\n')) {
const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
if (m && m[1]) names.push(m[1]);
}
return names;
}
/** Build the per-agent systemd relaunch commands (drain+relaunch via restart). */
export function buildRelaunchCommands(agentNames: string[]): string[][] {
return agentNames.map((name) => [
'systemctl',
'--user',
'restart',
`mosaic-agent@${name}.service`,
]);
}
/**
* Format a table showing all packages with their current/latest versions.
*/