fix(install): preserve user fleet data on re-seed + refresh active units (#631)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

CRITICAL data-loss in the routine update path. `mosaic update` auto-runs
install.sh keep-mode sync (#610); the rsync --delete honored PRESERVE_PATHS but
fleet/ was not listed, so the sync WIPED ~/.config/mosaic/fleet/roster.yaml (and
fleet/run, fleet/agents). Any user running `mosaic update` lost their fleet.

PRIMARY (data-loss):
- install.sh PRESERVE_PATHS += fleet/*.yaml, fleet/agents, fleet/run. The
  framework still SEEDS fleet/examples + fleet/roles + fleet/roster.schema.json
  (synced); the operator's roster, custom rosters, per-agent env, and heartbeat
  run dir are preserved.
- Made the cp (no-rsync) fallback GLOB-AWARE so fleet/*.yaml is preserved there
  too; fixed the restore to re-glob per pattern (restores only the user file,
  not the freshly-synced fleet/ dir).
- file-adapter.ts (TS installer): mirrored the preserve list for dual-installer
  parity. (syncDirectory is copy-only — never --delete — so it never had the
  bug; this is parity + belt-and-suspenders.)

SECONDARY (stale active units):
- refreshActiveFleetUnits(): the re-seed updates ~/.config/mosaic/systemd/user
  but systemd runs ~/.config/systemd/user, so shipped unit fixes (#627) did not
  take effect after update. `mosaic update` now copies the fresh mosaic-*.service
  → the active dir + daemon-reload (best-effort, only when a fleet is installed).

Verified: bash F6 fixture (roster/custom-yaml/agents/run survive + examples
refreshed + schema seeded), 20/20 migration matrix; TS file-adapter keep-mode
test; 2 refreshActiveFleetUnits unit tests. tsc/eslint/prettier/sanitize clean.

Refs #631

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-22 15:29:37 -05:00
parent d539d61e0e
commit b55deb4cc3
9 changed files with 216 additions and 13 deletions

View File

@@ -23,7 +23,15 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
# User-created content in these paths survives rsync --delete.
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
#
# fleet/* — the framework SEEDS only fleet/examples, fleet/roles, and
# fleet/roster.schema.json (synced normally). The user's own fleet files MUST
# survive `mosaic update` (which runs this sync automatically): the active
# roster (`fleet/roster.yaml` + any other `fleet/*.yaml`), per-agent env
# (`fleet/agents/`), and heartbeat run dir (`fleet/run/`). Without these, an
# update wipes the operator's fleet. Glob entries are honored by both the rsync
# path (`--exclude`) and the glob-aware cp fallback below.
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run")
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
# user must not edit them; a divergent copy is backed up once before overwrite).
@@ -179,15 +187,23 @@ sync_framework() {
return
fi
# Fallback: cp-based sync
# Fallback: cp-based sync. Glob-aware so entries like "fleet/*.yaml" preserve
# every matching user file (parity with the rsync --exclude path above).
local preserve_tmp=""
if [[ "$INSTALL_MODE" == "keep" ]]; then
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
local match rel
for path in "${PRESERVE_PATHS[@]}"; do
if [[ -e "$TARGET_DIR/$path" ]]; then
mkdir -p "$preserve_tmp/$(dirname "$path")"
cp -R "$TARGET_DIR/$path" "$preserve_tmp/$path"
fi
# Unquoted $path lets the glob expand against TARGET_DIR; nullglob makes a
# non-matching pattern vanish instead of staying literal.
shopt -s nullglob
for match in "$TARGET_DIR/"$path; do
[[ -e "$match" ]] || continue
rel="${match#"$TARGET_DIR/"}"
mkdir -p "$preserve_tmp/$(dirname "$rel")"
cp -R "$match" "$preserve_tmp/$rel"
done
shopt -u nullglob
done
fi
@@ -196,12 +212,19 @@ sync_framework() {
rm -rf "$TARGET_DIR/.git"
if [[ -n "$preserve_tmp" ]]; then
# Restore by re-globbing the SAME patterns against preserve_tmp, so each
# preserved item is restored at its own relative path (e.g. only
# fleet/roster.yaml is replaced — the freshly-synced fleet/examples stays).
for path in "${PRESERVE_PATHS[@]}"; do
if [[ -e "$preserve_tmp/$path" ]]; then
rm -rf "$TARGET_DIR/$path"
mkdir -p "$TARGET_DIR/$(dirname "$path")"
cp -R "$preserve_tmp/$path" "$TARGET_DIR/$path"
fi
shopt -s nullglob
for match in "$preserve_tmp/"$path; do
[[ -e "$match" ]] || continue
rel="${match#"$preserve_tmp/"}"
rm -rf "$TARGET_DIR/$rel"
mkdir -p "$TARGET_DIR/$(dirname "$rel")"
cp -R "$match" "$TARGET_DIR/$rel"
done
shopt -u nullglob
done
rm -rf "$preserve_tmp"
fi