#!/usr/bin/env bash # update.sh — multi-agent-mux-update (Issue #72): update an installed workspace in place. # Usage: # bash .agents/skills/multi-agent-mux-update/scripts/update.sh [-y|--yes] # # Phase 1 (this script, the installed code): publish .mam/update-in-progress # under the admission lock (lib.sh mam_update_admit is the other side), refuse # while anything live could see the tree change (lib_py/update.py precheck, a # live loop marker, a monitor.lock held by a monitor the skills did not start), # stop the monitor daemon, back up what the install can change into # .mam/update-backup/, then exec a copy of install_phase.sh outside the tree. # That copy runs the installer (MAM_INSTALLER_URL) in place, rolls back on # failure, and runs phase 2 (post_update.sh of the NEW tree) on success. # # Exit codes: # 0 = updated (registry converted or nothing to convert) # 1 = refused or failed; what was changed and restored is printed # 2 = invalid args set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)" export WORKSPACE_ROOT="$WS" export AGENT_SESSIONS_YAML="$WS/.mam/agent-sessions.yaml" # shellcheck disable=SC1091 source "$WS/.agents/skills/lib.sh" # shellcheck disable=SC1091 source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh" YES=0 while [ $# -gt 0 ]; do case "$1" in -y|--yes) YES=1; shift ;; -h|--help) sed -n '2,4p' "${BASH_SOURCE[0]}"; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; esac done MAM="$WS/.mam" MARKER="$MAM/update-in-progress" BK="$MAM/update-backup" PY="$(_delegate_py_bin)" if [ ! -f "$WS/.mam_deploy/remove.sh" ] && [ ! -f "$WS/remove.sh" ]; then echo "ERROR: $WS is not an installed MAM workspace (no .mam_deploy/remove.sh); a git checkout updates with git pull" >&2 exit 1 fi if [ "$YES" != "1" ]; then if [ ! -t 0 ]; then echo "ERROR: not a terminal; run with -y/--yes" >&2 exit 1 fi ans="" read -r -p "Update MAM in $WS to the latest version? [y/N]: " ans || ans="" case "$ans" in y|Y|yes|Yes|YES) ;; *) echo "Update cancelled."; exit 0 ;; esac fi # --- the marker, published under the exclusive admission lock (decision 8) --- mkdir -p "$MAM" exec 7>>"$MAM/update-admission.lock" case "$("$PY" -m lib_py.pane_lock acquire --fd 7 --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in locked) ;; timeout) echo "ERROR: a create, resume, loop, delegate submit, stop or orc-onboard kept the admission lock past MAM_UPDATE_ADMISSION_WAIT_S; nothing was changed, try again" >&2 exit 1 ;; *) echo "ERROR: the admission lock $MAM/update-admission.lock could not be decided (lib_py.pane_lock); nothing was changed" >&2 exit 1 ;; esac if [ -f "$MARKER" ] && [ "$(_mam_lock_holder_state "$MARKER")" = "stale" ]; then echo "NOTE: stale update marker (pid $(_mam_lock_field "$MARKER" pid)) taken over; its update is gone" fi marker_rc=0 mam_acquire_loop_lock "$MARKER" || marker_rc=$? exec 7>&- case "$marker_rc" in 0) ;; 3) echo "ERROR: another update is in progress (pid $(_mam_lock_field "$MARKER" pid)); nothing was changed" >&2; exit 1 ;; *) echo "ERROR: cannot tell whether another update is in progress (ps unavailable?); nothing was changed" >&2; exit 1 ;; esac TMPD="" MONITOR_WAS_RUNNING=0 RESTART_MONITOR=0 _phase1_exit() { local rc=$? if [ "$RESTART_MONITOR" = "1" ]; then _restart_monitor || true fi mam_release_loop_lock "$MARKER" || true [ -z "$TMPD" ] || rm -rf "$TMPD" exit "$rc" } trap _phase1_exit EXIT _restart_monitor() { if [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then echo "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)" return 0 fi local pid pid=$(start_watchdog update "$WS") || { echo "ERROR: could not restart the monitor daemon" >&2; return 1; } echo "monitor: restarted (PID $pid)" } _monitor_json() { "$PY" -m lib_py.update monitor --workspace "$WS" } _json_field() { "$PY" -c 'import json,sys; v=json.load(sys.stdin)[sys.argv[1]]; print(" ".join(v) if isinstance(v, list) else v)' "$1" } # --- pre-check: refuse before any change (decisions 1, 7; U3) --- BLOCKERS="" _block() { BLOCKERS="${BLOCKERS} - $1 "; } if [ -f "$MAM/loop-guard-active" ]; then loop_st="$(_mam_lock_holder_state "$MAM/loop-guard-active")" case "$loop_st" in stale) echo "NOTE: stale loop marker $MAM/loop-guard-active (its loop is gone); not a reason to refuse" ;; *) _block "a review loop is running (pid $(_mam_lock_field "$MAM/loop-guard-active" pid), holder $loop_st); wait for it to end" ;; esac fi STATE_JSON="" if ! STATE_JSON="$(load_state_json strict)"; then _block "the registry $AGENT_SESSIONS_YAML cannot be read; fix it first" STATE_JSON='{}' fi REAL_HERDR="$(_resolve_real_herdr_path || true)" if [ -z "$REAL_HERDR" ]; then _block "no herdr binary found on PATH, so the live agents cannot be listed" else pre_out="" pre_rc=0 pre_out="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.update precheck --workspace "$WS" --herdr "$REAL_HERDR")" || pre_rc=$? if [ "$pre_rc" -ne 0 ]; then while IFS= read -r line; do [ -n "$line" ] && _block "$line" done <<< "${pre_out:-lib_py.update precheck failed (rc $pre_rc)}" fi fi MON_JSON="$(_monitor_json)" || { _block "cannot inspect the monitor daemon (lib_py.update monitor)"; MON_JSON='{"pids": [], "lock": "free"}'; } MON_PIDS="$(printf '%s' "$MON_JSON" | _json_field pids)" MON_LOCK="$(printf '%s' "$MON_JSON" | _json_field lock)" if [ -z "$MON_PIDS" ] && [ "$MON_LOCK" = "held" ]; then _block "$MAM/monitor.lock is held by a monitor the skills did not start (not 'bash $WS/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh --subscribe'); stop it (find it with: pgrep -af reconcile.sh) and try again" fi if [ -n "$BLOCKERS" ]; then echo "ERROR: update refused; nothing was changed:" >&2 printf '%s' "$BLOCKERS" >&2 exit 1 fi # --- stop the monitor daemon (decision 2) --- _monitor_gone() { local p for p in "$@"; do kill -0 "$p" 2>/dev/null && return 1 done [ "$(_monitor_json | _json_field lock)" = "free" ] } _wait_monitor_gone() { local deadline=$(( $(date +%s) + $1 )) shift while :; do _monitor_gone "$@" && return 0 [ "$(date +%s)" -lt "$deadline" ] || return 1 sleep 0.5 done } if [ -n "$MON_PIDS" ]; then MONITOR_WAS_RUNNING=1 # shellcheck disable=SC2086 set -- $MON_PIDS kids="" for p in "$@"; do kids="$kids $(pgrep -P "$p" 2>/dev/null | tr '\n' ' ' || true)"; done echo "monitor: stopping the daemon (PID $MON_PIDS)" kill -TERM "$@" 2>/dev/null || true if ! _wait_monitor_gone "$(mam_int_knob MAM_UPDATE_MONITOR_STOP_S 30 1)" "$@"; then # SIGKILL only what is still this workspace's monitor: a PID that ended # during the wait may already be another process's. still="" for p in $(_monitor_json | _json_field pids); do case " $* " in *" $p "*) still="$still $p" ;; esac done # shellcheck disable=SC2086 set -- $still for p in "$@"; do kids="$kids $(pgrep -P "$p" 2>/dev/null | tr '\n' ' ' || true)"; done echo "WARNING: the monitor did not stop within MAM_UPDATE_MONITOR_STOP_S; sending SIGKILL to $* $kids" >&2 # shellcheck disable=SC2086 kill -KILL "$@" $kids 2>/dev/null || true # shellcheck disable=SC2086 if _wait_monitor_gone 5 "$@" $kids; then echo "ERROR: the monitor did not stop in time; it was killed and is started again. Nothing else was changed." >&2 _restart_monitor || true exit 1 fi echo "ERROR: could not restore the monitor state: PIDs still alive or monitor.lock still held ($*$kids; lock $(_monitor_json | _json_field lock)). Nothing else was changed." >&2 exit 1 fi RESTART_MONITOR=1 fi TMPD="$(mktemp -d "${TMPDIR:-/tmp}/mam-update.XXXXXX")" cp "$SCRIPT_DIR/install_phase.sh" "$TMPD/install_phase.sh" # --- a leftover backup of an update that died (U4) --- if [ -e "$BK" ]; then left_st="$(cat "$BK/state" 2>/dev/null || true)" case "$left_st" in ""|backing-up|installed) echo "NOTE: removing the backup a previous update left (state ${left_st:-none}); nothing to restore" rm -rf "$BK" ;; installing) echo "NOTE: a previous update died during its install; restoring its backup (the registry is left as it is)" if ! bash "$TMPD/install_phase.sh" restore "$WS" --skip-registry; then RESTART_MONITOR=0 echo "ERROR: restoring the previous update's backup failed; it is kept at $BK. Nothing more was changed; the monitor stays stopped." >&2 exit 1 fi rm -rf "$BK" echo "the previous update was rolled back" ;; *) echo "ERROR: $BK has an unknown state '$left_st'; it is kept and nothing was changed. Check it and remove it by hand." >&2 exit 1 ;; esac fi # --- the rollback backup (decisions 3, 10; U4) --- _set_state() { printf '%s\n' "$1" > "$BK/state.tmp" && mv -f "$BK/state.tmp" "$BK/state"; } ITEMS=".agents .mam_deploy AGENTS.md MESSAGING.md BOOTSTRAP.md BOOTSTRAP.ko.md .mam.env.example .mam.env .gitignore remove.sh update.sh .venv .mam/install_manifest.txt .mam/install_state .mam/asset_hashes.txt .mam/version.txt .mam/base .mam/skill-backups" _backup() { mkdir -p "$BK/files" || return 1 _set_state backing-up || return 1 : > "$BK/items" && : > "$BK/absent" || return 1 local rel for rel in $ITEMS; do printf '%s\n' "$rel" >> "$BK/items" || return 1 if [ -e "$WS/$rel" ] || [ -L "$WS/$rel" ]; then mkdir -p "$BK/files/$(dirname "$rel")" && cp -pPR "$WS/$rel" "$BK/files/$rel" || return 1 else printf '%s\n' "$rel" >> "$BK/absent" || return 1 fi done "$PY" -m lib_py.update backup-registry --workspace "$WS" --dest "$BK/registry" >/dev/null } echo "backup: $BK" if ! _backup; then rm -rf "$BK" echo "ERROR: the backup could not be made; nothing was changed" >&2 exit 1 fi # --- hand over to the copy (decision 5): same pid, so the marker stays live --- RESTART_MONITOR=0 trap - EXIT exec bash "$TMPD/install_phase.sh" run "$WS" "$MONITOR_WAS_RUNNING" "$TMPD"