#!/usr/bin/env bash # reset.sh — multi-agent-mux-reset (Issue #78): purge the control root's # running resident agents and recreate each with the same name, agent, role, # herdr server and workspace label, one at a time, with onboarding. # Usage: # bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh [-y|--yes] [--dry-run] set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)" export WORKSPACE_ROOT="$WS" export AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WS/.mam/agent-sessions.yaml}" export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" usage() { cat <<'USAGE' Usage: bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh [-y|--yes] [--dry-run] Options: -y, --yes Confirm purging and recreating the control root's running agents --dry-run Inspect targets, skipped rows, spec progress and blockers without changing anything -h, --help Show this help USAGE } YES=0 DRY_RUN=0 while [ $# -gt 0 ]; do case "$1" in -y|--yes) YES=1; shift ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; usage >&2; exit 2 ;; esac done if [ "$DRY_RUN" != "1" ] && [ "$YES" != "1" ] && [ ! -t 0 ]; then echo "ERROR: not a terminal; run with -y/--yes (or --dry-run to inspect)" >&2 exit 3 fi MAM="$WS/.mam" MARKER="${MAM_LOOP_MARKER:-$MAM/loop-guard-active}" SPEC_FILE="$MAM/reset-spec.json" # shellcheck disable=SC1091 source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh" if [ "$DRY_RUN" = "1" ]; then if [ -x "$WS/.venv/bin/python" ]; then PY="$WS/.venv/bin/python" elif command -v python3 >/dev/null 2>&1; then PY="python3" else PY="python" fi DRY_ARGS=() if [ -f "$MAM/update-in-progress" ]; then upd_st="$(_mam_lock_holder_state "$MAM/update-in-progress")" case "$upd_st" in stale) echo "NOTE: stale update marker $MAM/update-in-progress (its update is gone); not a reason to refuse" ;; *) DRY_ARGS+=(--refusal "an update of $WS is in progress (pid $(_mam_lock_field "$MAM/update-in-progress" pid), holder $upd_st); wait for it to finish") ;; esac fi if [ -f "$MARKER" ]; then loop_st="$(_mam_lock_holder_state "$MARKER")" case "$loop_st" in stale) echo "NOTE: stale loop marker $MARKER (its loop is gone); not a reason to refuse" ;; *) DRY_ARGS+=(--refusal "a review loop or another reset is running (pid $(_mam_lock_field "$MARKER" pid), holder $loop_st); wait for it to end") ;; esac fi dry_rc=0 "$PY" -m lib_py.reset dry-run --workspace "$WS" ${DRY_ARGS[@]+"${DRY_ARGS[@]}"} || dry_rc=$? if [ "$dry_rc" -eq 0 ]; then exit 0 fi exit 1 fi # shellcheck disable=SC1091 source "$WS/.agents/skills/lib.sh" PY="$(_delegate_py_bin)" mam_update_admit "$WS" || exit 1 _acquire_rc=0 mam_acquire_loop_lock "$MARKER" || _acquire_rc=$? case "$_acquire_rc" in 0) ;; 3) mam_update_admit_release _holder_pid="$(_mam_lock_field "$MARKER" "pid" 2>/dev/null || echo "unknown")" echo "ERROR: reset refused; nothing was changed:" >&2 echo " - a review loop or another reset is running (pid $_holder_pid); wait for it to end" >&2 exit 1 ;; *) mam_update_admit_release echo "ERROR: reset refused; nothing was changed:" >&2 echo " - cannot determine whether a review loop or another reset is running (ps unavailable?)" >&2 exit 1 ;; esac _release_reset_lock() { mam_release_loop_lock "$MARKER" || true } _on_reset_signal() { local sig_rc="$1" _release_reset_lock exit "$sig_rc" } trap _release_reset_lock EXIT trap '_on_reset_signal 130' INT trap '_on_reset_signal 143' TERM trap '_on_reset_signal 129' HUP mam_update_admit_release STATE_JSON="" if ! STATE_JSON="$(load_state_json strict)"; then echo "ERROR: reset refused; nothing was changed:" >&2 echo " - the registry $AGENT_SESSIONS_YAML cannot be read; fix it first" >&2 exit 1 fi pre_out="" pre_rc=0 pre_out="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset precheck --workspace "$WS")" || pre_rc=$? if [ "$pre_rc" -ne 0 ]; then echo "ERROR: reset refused; nothing was changed:" >&2 while IFS= read -r line; do [ -n "$line" ] && echo " - $line" >&2 done <<< "${pre_out:-lib_py.reset precheck failed (rc $pre_rc)}" exit 1 fi if [ -f "$SPEC_FILE" ]; then MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset dry-run --workspace "$WS" || exit 1 if [ "$YES" != "1" ]; then ans="" read -r -p "Resume reset from $SPEC_FILE? [y/N]: " ans || ans="" case "$ans" in y|Y|yes|Yes|YES) ;; *) echo "Reset cancelled; nothing was changed"; exit 0 ;; esac fi else PLAN_JSON="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset plan --workspace "$WS")" || exit 1 TARGET_COUNT="$(printf '%s' "$PLAN_JSON" | "$PY" -c 'import json,sys; print(len(json.load(sys.stdin).get("targets", [])))')" MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset dry-run --workspace "$WS" || exit 1 if [ "$TARGET_COUNT" = "0" ]; then echo "nothing to reset" exit 0 fi if [ "$YES" != "1" ]; then ans="" read -r -p "Purge and recreate $TARGET_COUNT agents? [y/N]: " ans || ans="" case "$ans" in y|Y|yes|Yes|YES) ;; *) echo "Reset cancelled; nothing was changed"; exit 0 ;; esac fi printf '%s' "$PLAN_JSON" | "$PY" -m lib_py.reset write-spec --workspace "$WS" || exit 1 fi ENTRIES_OUT="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset entries --workspace "$WS" --warn-new)" || exit 1 ENTRY_NAMES=() while IFS= read -r line; do [ -n "$line" ] || continue eval "$line" ENTRY_NAMES+=("$R_NAME") done <<< "$ENTRIES_OUT" SKIPPED_ARGS=() FAILED_SPEC="" for target_name in ${ENTRY_NAMES[@]+"${ENTRY_NAMES[@]}"}; do if ! STATE_JSON="$(load_state_json strict)"; then FAILED_SPEC="$target_name:stop:1" break fi entry_line="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset entries --workspace "$WS" --session "$target_name")" || { FAILED_SPEC="$target_name:stop:1" break } eval "$entry_line" case "$R_STATE" in done|created) continue ;; pending) STOP_CMD=(bash "$SKILL_DIR/multi-agent-mux-stop/scripts/stop_session.sh" --session "$R_NAME" --agent "$R_AGENT") if [ -n "$R_SERVER" ]; then STOP_CMD+=(--herdr-session "$R_SERVER") fi STOP_CMD+=(--reason reset) echo "reset [$R_NAME]: ${STOP_CMD[*]}" stop_rc=0 "${STOP_CMD[@]}" || stop_rc=$? if [ "$stop_rc" -ne 0 ]; then FAILED_SPEC="$R_NAME:stop:$stop_rc" break fi if ! STATE_JSON="$(load_state_json strict)"; then FAILED_SPEC="$R_NAME:stop:1" break fi if [ -z "$R_OLD_ID" ]; then MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset record-id --workspace "$WS" --session "$R_NAME" || { FAILED_SPEC="$R_NAME:stop:1" break } fi entry_line="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset entries --workspace "$WS" --session "$R_NAME")" || { FAILED_SPEC="$R_NAME:stop:1" break } eval "$entry_line" if [ -z "$R_ROW_ID" ]; then echo "skipped $R_NAME: no conversation id is recorded, so a purge would drop the row and leave the conversation on disk; resume it, or purge it by hand (stop_session.sh --session $R_NAME --purge-conversation --yes) and rerun the reset to recreate it" SKIPPED_ARGS+=(--skipped "$R_NAME:no_id") continue fi echo "reset [$R_NAME]: ${STOP_CMD[*]} --purge-conversation --yes" purge_rc=0 "${STOP_CMD[@]}" --purge-conversation --yes || purge_rc=$? if [ "$purge_rc" -eq 4 ]; then echo "skipped $R_NAME: its purge was refused (stop exit 4); the row (now stopped, id recorded) and the conversation are kept; the processes above hold it - they are not killed; rerun the reset once they end" SKIPPED_ARGS+=(--skipped "$R_NAME:purge_refused") continue fi if [ "$purge_rc" -ne 0 ]; then FAILED_SPEC="$R_NAME:stop:$purge_rc" break fi R_STATE="purged" ;; esac if [ "$R_STATE" = "purged" ]; then CREATE_CMD=(bash "$SKILL_DIR/multi-agent-mux-create/scripts/create_session.sh" --workspace "$WS" --agent "$R_AGENT" --role "$R_ROLE" --session "$R_NAME") if [ -n "$R_SERVER" ]; then CREATE_CMD+=(--herdr-session "$R_SERVER") fi if [ -n "$R_LABEL" ]; then CREATE_CMD+=(--herdr-workspace "$R_LABEL") fi echo "reset [$R_NAME]: ${CREATE_CMD[*]}" create_rc=0 "${CREATE_CMD[@]}" || create_rc=$? if [ "$create_rc" -ne 0 ]; then FAILED_SPEC="$R_NAME:create:$create_rc" break fi fi done STATE_JSON="$(load_state_json strict)" || exit 1 TABLE_ARGS=(--workspace "$WS") if [ ${#SKIPPED_ARGS[@]} -gt 0 ]; then TABLE_ARGS+=("${SKIPPED_ARGS[@]}") fi if [ -n "$FAILED_SPEC" ]; then TABLE_ARGS+=(--failed "$FAILED_SPEC") fi table_rc=0 MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset table "${TABLE_ARGS[@]}" || table_rc=$? case "$table_rc" in 0) rm -f "$SPEC_FILE" exit 0 ;; 4) exit 4 ;; 5) exit 5 ;; *) exit 1 ;; esac