feat(reset): multi-agent-mux-reset skill - reset.sh, SKILL.md, packaging (#78)
This commit is contained in:
1 parent
ca2532307f
commit
fb1895030f
12 files changed
+1248
-58
No files matched your search
@@ -13,6 +13,8 @@ import datetime
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -38,8 +40,50 @@ def spec_path(workspace: str) -> str:
|
||||
return os.path.join(_real(workspace), SPEC_REL)
|
||||
|
||||
|
||||
def _state() -> dict:
|
||||
return json.loads(os.environ.get("MAM_STATE_JSON") or "{}") or {}
|
||||
def _state(workspace: str = "") -> dict:
|
||||
raw = os.environ.get("MAM_STATE_JSON")
|
||||
if raw is not None:
|
||||
return json.loads(raw or "{}") or {}
|
||||
yaml_path = os.environ.get("AGENT_SESSIONS_YAML") or (
|
||||
os.path.join(_real(workspace), ".mam", "agent-sessions.yaml") if workspace else ""
|
||||
)
|
||||
if not yaml_path:
|
||||
return {}
|
||||
db_path = os.path.splitext(yaml_path)[0] + ".db"
|
||||
if os.path.exists(db_path):
|
||||
tmp_dir = tempfile.mkdtemp(prefix="mam-reset-ro.")
|
||||
try:
|
||||
tmp_db = os.path.join(tmp_dir, "agent-sessions.db")
|
||||
shutil.copyfile(db_path, tmp_db)
|
||||
for suf in ("-wal", "-shm"):
|
||||
if os.path.exists(db_path + suf):
|
||||
shutil.copyfile(db_path + suf, tmp_db + suf)
|
||||
conn = sqlite3.connect(tmp_db, timeout=60.0)
|
||||
try:
|
||||
d: dict = {}
|
||||
row = conn.execute("SELECT data FROM state WHERE id=1").fetchone()
|
||||
if row:
|
||||
d = json.loads(row[0])
|
||||
cursor = conn.execute("SELECT data FROM sessions")
|
||||
d["herdr_sessions"] = [json.loads(r[0]) for r in cursor.fetchall()]
|
||||
return d
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
raise ValueError("the registry %s cannot be read (%s); fix it first" % (yaml_path, exc))
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
if os.path.exists(yaml_path):
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_path, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if data is not None and not isinstance(data, dict):
|
||||
raise ValueError("top-level YAML is not a mapping")
|
||||
return data or {}
|
||||
except Exception as exc:
|
||||
raise ValueError("the registry %s cannot be read (%s); fix it first" % (yaml_path, exc))
|
||||
return {}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- D2: target selection
|
||||
@@ -638,8 +682,13 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
return 0
|
||||
|
||||
try:
|
||||
state = _state()
|
||||
state = _state(ws)
|
||||
except ValueError as exc:
|
||||
if a.cmd == "dry-run":
|
||||
refs = list(a.refusal or []) + [str(exc)]
|
||||
out, rc = format_dry_run(ws, {}, os.environ, extra_refusals=refs)
|
||||
print(out)
|
||||
return rc
|
||||
if a.cmd == "precheck":
|
||||
print("registry: cannot parse MAM_STATE_JSON (%s)" % exc)
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: multi-agent-mux-reset
|
||||
description: "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. Persists .mam/reset-spec.json before the first purge so an interrupted or skipped run resumes where it left off."
|
||||
version: 4.12.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [terminal, herdr]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [reset, purge, recreate, lifecycle, onboarding]
|
||||
related_skills: [multi-agent-mux-stop, multi-agent-mux-create, multi-agent-mux-status]
|
||||
---
|
||||
|
||||
# Multi-Agent Reset
|
||||
|
||||
```bash
|
||||
bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh --yes [--dry-run]
|
||||
```
|
||||
|
||||
| Item | Behaviour |
|
||||
|---|---|
|
||||
| Targets | Every registry row with `status: running` and `realpath(pane.cwd) == realpath(WS)`. Worktree rows, `stopped`/`terminated` rows, and `orchestrator_uuids` are skipped and listed with their reason. |
|
||||
| Confirmation | `[y/N]` prompt; without a terminal `-y`/`--yes` is required (exits `3` otherwise). `--dry-run` inspects without `--yes` and changes nothing. |
|
||||
| Prechecks | Refuses before any change when an open job exists (prints the `registry.py ... close --request cancelled` command), when `.mam/loop-guard-active` or `.mam/update-in-progress` is held, when a target row lacks a valid agent or role, or when the caller itself is one of the targets. |
|
||||
| Lock & concurrency | Takes update admission (`mam_update_admit`), acquires `.mam/loop-guard-active` (`mam_acquire_loop_lock`), then releases update admission so `run_loop.sh`, `update.sh`, and concurrent resets refuse while the reset runs. |
|
||||
| Spec file & rerun | Writes `.mam/reset-spec.json` atomically before the first stop/purge and removes it only when all targets reach `done`. Rerunning resumes from the spec file without re-purging already recreated agents (`created` or `done`). |
|
||||
| Sequence per target | Sequential (never parallel): ① normal `stop_session.sh --reason reset` (captures verified ID onto the row while herdr is live and sets `status: stopped`), ② checks that the row has a recorded conversation ID, ③ `stop_session.sh --reason reset --purge-conversation --yes` (using the row's recorded ID, never a CLI UUID), then `create_session.sh` with the original name, agent, role, `--herdr-session`, and `--herdr-workspace` (onboarding enabled by default). |
|
||||
| When skipped (exit 4) | If step ② finds no recorded ID or step ③ exits `4` (in-use gate), the target is skipped and the reset continues to the next agent: the skipped agent is stopped but resumable (its row keeps its id) and its conversation files are untouched; rerun once its holders end, or resume it. |
|
||||
|
||||
| Exit | Meaning |
|
||||
|---|---|
|
||||
| 0 | All targeted agents reached `done` and `.mam/reset-spec.json` was removed; or nothing to reset, cancelled at prompt, or `--dry-run` with no blockers |
|
||||
| 1 | Refused by precheck (nothing changed), corrupt/mismatched `.mam/reset-spec.json`, or a `stop`/`create` step failed (`.mam/reset-spec.json` is kept so rerunning resumes) |
|
||||
| 2 | Invalid arguments |
|
||||
| 3 | Not a terminal and `-y`/`--yes` was not passed; nothing was changed |
|
||||
| 4 | Completed the pass, but one or more agents were skipped (purge refused with stop exit 4, or no conversation ID recorded); `.mam/reset-spec.json` is kept for rerun |
|
||||
| 5 | No agent was skipped or failed, but one or more newly created post-discovered agents do not have their new conversation ID recorded yet; `.mam/reset-spec.json` is kept so rerunning re-checks without re-purging |
|
||||
| other | Under `set -e`, an unexpected sub-step failure passes its nonzero exit code through |
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- **Irreversible**: purged conversations cannot be recovered. Confirm with `--dry-run` first.
|
||||
- **No external process termination**: processes holding conversation files open (reported via `lsof` in `in_use_hints`) are never signalled by MAM; end them yourself when safe, then rerun.
|
||||
- **Shared stores preserved**: Grok's shared `memory-v2.db` is left intact during purge; only session rows and per-session files are removed.
|
||||
- **Orchestrator hook during reset**: while `.mam/loop-guard-active` is held by the reset, the AGY orchestrator's direct file-edit hook (`loop_delegation_guard.sh`) blocks direct edits until the reset finishes.
|
||||
- **Post-precheck job submissions**: `multi-agent-mux-delegate-job submit` does not check `.mam/loop-guard-active`; do not submit new jobs to resident agents while a reset is running.
|
||||
- **Same-type post-discovered ordering**: when resetting multiple post-discovered agents of the same type, the second `create_session.sh` waits up to `MAM_ID_ORDER_WAIT_S` for the first agent's ID to be recorded; if it times out, rerun the reset once the ID is recorded.
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
#!/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
|
||||
}
|
||||
trap _release_reset_lock EXIT INT TERM 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
|
||||
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
|
||||
Reference in new issue
Block a user