fix(reset,stop): warn before a reset purges, skip and prune stale registries, refuse a guessed purge

reset.sh without --yes says the conversations are deleted for good before it
asks; without a terminal the refusal says so too. A registry that cannot be
created is skipped, since nothing can be registered there, and the directories
the fence created are removed on exit when still empty. stop --purge-conversation
refuses a conversation the row does not record instead of deleting the folder's
newest one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-31 07:56:07 +09:00
1 parent 12e140322d
commit 022c656052
9 files changed
+198 -25

No files matched your search

@@ -154,6 +154,13 @@ def decide_stop(state: Dict[str, Any], name: str, agent: str, cwd: str,
"purge_uuid": "",
"note": f"{found} is also recorded by {others}; registry needs fixing",
}
if purge and found and found != own:
return {
"captured": "",
"status": "purge-unrecorded",
"purge_uuid": "",
"note": f"{found} is this folder's newest conversation, not an id this row records",
}
return {
"captured": found,
"status": "resolved" if found else "no-candidate",
+40 -5
View File
@@ -22,7 +22,7 @@ from typing import Dict, Iterator, List, Mapping, Optional, Tuple
from lib_py.agents.base import AGENT_SESSION_ENV, POST_DISCOVERED
from lib_py.agents.registry import agent_of_row, get_adapter, own_key
from lib_py.delegation.mqtt_common import registry_lock
from lib_py.delegation.mqtt_common import LOCK_FILENAME, registry_lock
from lib_py.delegation.registry import RESET_MARKER, reset_marker
from lib_py.update import jobs_dirs, open_jobs, row_server
from lib_py.verify_session import verify_session_uuid
@@ -497,7 +497,10 @@ def fence_registries(workspace: str, state: dict, env: Mapping[str, str]) -> Ite
fenced by another live (or undecidable) reset - registries may be shared
between workspaces; its marker is left as it is. This workspace's own
registries refuse every registration; another control plane's only those
addressed to this registry's rows. The marker copies .mam/reset-in-progress."""
addressed to this registry's rows. The marker copies .mam/reset-in-progress.
Yields ("created", path) per directory level it makes, deepest first, then
("fenced", real path). A registry that cannot be created is skipped: nothing
can be registered there."""
with open(os.path.join(workspace, ".mam", "reset-in-progress"), encoding="utf-8") as fh:
base = "".join(l + "\n" for l in fh.read().splitlines() if l.split("=", 1)[0] in ("pid", "lstart", "started"))
me = dict(l.split("=", 1) for l in base.splitlines()).get("pid", "")
@@ -505,7 +508,17 @@ def fence_registries(workspace: str, state: dict, env: Mapping[str, str]) -> Ite
rows = state.get("herdr_sessions") or []
sessions = ",".join("herdr:%s" % s["name"] for s in rows if s.get("name"))
for d, addressed_only in jobs_dirs(workspace, state, env):
missing = []
p = os.path.abspath(d)
while not os.path.lexists(p):
missing.append(p)
p = os.path.dirname(p)
try:
os.makedirs(d, exist_ok=True)
except OSError:
continue
for p in missing:
yield "created", p
with registry_lock(d):
held, fields = reset_marker(d)
if held in ("live", "unknown") and fields.get("pid") != me:
@@ -516,7 +529,21 @@ def fence_registries(workspace: str, state: dict, env: Mapping[str, str]) -> Ite
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(base + ("sessions=%s\n" % sessions if addressed_only else ""))
os.replace(tmp, os.path.join(d, RESET_MARKER))
yield _real(d)
yield "fenced", _real(d)
def prune_created(paths: List[str]) -> None:
"""Remove the directories fence_registries created, deepest first, each only
while nothing but its registry lock file is left in it."""
for d in paths:
try:
if os.path.isfile(os.path.join(d, LOCK_FILENAME)):
with registry_lock(d):
if os.listdir(d) == [LOCK_FILENAME]:
os.unlink(os.path.join(d, LOCK_FILENAME))
os.rmdir(d)
except OSError:
continue
def target_id_owned(state: dict, session_name: str) -> bool:
@@ -795,6 +822,10 @@ def main(argv: Optional[List[str]] = None) -> int:
s = sub.add_parser("fence")
s.add_argument("--workspace", required=True)
s = sub.add_parser("prune-created")
s.add_argument("--workspace", required=True)
s.add_argument("paths", nargs="*")
s = sub.add_parser("id-owned")
s.add_argument("--workspace", required=True)
s.add_argument("--session", required=True)
@@ -886,8 +917,8 @@ def main(argv: Optional[List[str]] = None) -> int:
if a.cmd == "fence":
try:
for d in fence_registries(ws, state, os.environ):
print("fenced %s" % d, flush=True)
for kind, d in fence_registries(ws, state, os.environ):
print("%s %s" % (kind, d), flush=True)
except FenceBusy as exc:
print("refused %s" % exc)
return 1
@@ -896,6 +927,10 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1
return 0
if a.cmd == "prune-created":
prune_created(a.paths)
return 0
if a.cmd == "id-owned":
return 0 if target_id_owned(state, a.session) else 1
@@ -21,9 +21,9 @@ 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)`. Rows with any other status, worktree 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. |
| Confirmation | Without `-y`/`--yes` it warns that the conversations are deleted for good and asks `[y/N]`; without a terminal it cannot ask, so `-y`/`--yes` is required (exits `3` otherwise). `--dry-run` inspects without `--yes` and changes nothing. An agent has no terminal here: show the user the `--dry-run` targets, say the deletion cannot be undone, and pass `--yes` only after they confirm. |
| 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, when a running `claude`/`grok` target has no recorded conversation id (the monitor had not pinned one; record it, or stop/purge it by hand first), when `.mam/reset-spec.json` is invalid, or when the caller itself is one of the targets. |
| Lock & concurrency | Takes the update admission lock exclusively, acquires `.mam/loop-guard-active` and publishes `.mam/reset-in-progress`, then releases the admission lock and runs the prechecks. `run_loop.sh`, `update.sh` and another reset refuse on the loop marker. The reset then writes `.reset-in-progress` into every job registry its open-job check reads (`.mam/jobs`, `MAM_JOBS_DIR`, and a row's other `control_plane.mam_jobs_dir`; created when absent), each under that registry's lock, before the check. It refuses, changing nothing, when a registry shared with another workspace is already fenced by that workspace's live reset; it never replaces or removes another reset's marker. A registration (`multi-agent-mux-delegate-job submit`, `registry.py register`, the registry API) into a fenced registry - by any path to that directory - refuses; in another control plane's registry only for this workspace's sessions. The onboarding jobs of the reset's own `create_session.sh` are let through. |
| Lock & concurrency | Takes the update admission lock exclusively, acquires `.mam/loop-guard-active` and publishes `.mam/reset-in-progress`, then releases the admission lock and runs the prechecks. `run_loop.sh`, `update.sh` and another reset refuse on the loop marker. The reset then writes `.reset-in-progress` into every job registry its open-job check reads (`.mam/jobs`, `MAM_JOBS_DIR`, and a row's other `control_plane.mam_jobs_dir`; created when absent, skipped when it cannot be created, since nothing can be registered there), each under that registry's lock, before the check, and on exit removes the directories it created if they are still empty. It refuses, changing nothing, when a registry shared with another workspace is already fenced by that workspace's live reset; it never replaces or removes another reset's marker. A registration (`multi-agent-mux-delegate-job submit`, `registry.py register`, the registry API) into a fenced registry - by any path to that directory - refuses; in another control plane's registry only for this workspace's sessions. The onboarding jobs of the reset's own `create_session.sh` are let through. |
| 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): ⓪ re-checks that its row still has the spec's agent and herdr server and that no job addressed to it is open, ① normal `stop_session.sh --reason reset` (a post-discovered agent's id is captured onto the row while herdr is live; `status: stopped`), ② checks that the row's id is the one recorded before the stop and, for a post-discovered agent, that it still verifies as a conversation of the row's folder, ③ `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) | The target is skipped and the reset continues to the next agent when step ⓪ finds a changed row (`row changed`) or an open job (`job open`) - nothing was changed for it -, or when step ② finds no id (`no conversation id recorded`) or an id other than the recorded one or one that does not verify for the row's folder (`id not verified`), or step ③ exits `4` (`purge refused`, in-use gate) - it is stopped but resumable (its row keeps its id) and its conversation files are untouched. Rerun once the cause is gone, or resume it. |
@@ -36,7 +36,7 @@ while [ $# -gt 0 ]; do
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
echo "ERROR: not a terminal, so the reset cannot ask; it permanently deletes conversations: run with -y/--yes once that is confirmed (or --dry-run to inspect)" >&2
exit 3
fi
@@ -115,11 +115,13 @@ case "$_acquire_rc" in
*) _refuse_locked "cannot determine whether a review loop or another reset is running (ps unavailable?)" ;;
esac
FENCED=()
CREATED=()
_release_reset_lock() {
local d
for d in ${FENCED[@]+"${FENCED[@]}"}; do
[ "$(_mam_lock_field "$d/.reset-in-progress" pid 2>/dev/null || true)" != "$$" ] || rm -f "$d/.reset-in-progress"
done
[ "${#CREATED[@]}" -eq 0 ] || MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset prune-created --workspace "$WS" "${CREATED[@]}" || true
mam_release_loop_lock "$RESET_MARKER" || true
mam_release_loop_lock "$MARKER" || true
}
@@ -154,6 +156,7 @@ fence_refusal=""
while IFS= read -r line; do
case "$line" in
"fenced "*) FENCED+=("${line#fenced }") ;;
"created "*) CREATED+=("${line#created }") ;;
"refused "*) fence_refusal="${line#refused }" ;;
esac
done <<< "$fence_out"
@@ -174,16 +177,19 @@ if [ "$pre_rc" -ne 0 ]; then
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=""
_confirm_purge() {
local ans=""
echo "WARNING: this permanently deletes the conversations of the agents to reset above; they cannot be recovered."
read -r -p "$1 [y/N]: " ans || ans=""
case "$ans" in
y|Y|yes|Yes|YES) ;;
*) echo "Reset cancelled; nothing was changed"; exit 0 ;;
esac
fi
}
if [ -f "$SPEC_FILE" ]; then
MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset dry-run --workspace "$WS" || exit 1
[ "$YES" = "1" ] || _confirm_purge "Really resume the reset from $SPEC_FILE?"
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", [])))')"
@@ -192,14 +198,7 @@ else
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
[ "$YES" = "1" ] || _confirm_purge "Really purge and recreate $TARGET_COUNT agents?"
printf '%s' "$PLAN_JSON" | "$PY" -m lib_py.reset write-spec --workspace "$WS" || exit 1
fi
+1 -1
View File
@@ -58,7 +58,7 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
| Exit | Meaning |
|---|---|
| 0 | Stopped; or already `stopped` (no-op, except with `--purge-conversation`, which still purges) |
| 1 | From the checks before the stop, nothing was changed: YAML not found, its row could not be read, the session is neither registered nor on the herdr server, or an update of the workspace is in progress (`multi-agent-mux-update`). **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) |
| 1 | From the checks before the stop, nothing was changed: YAML not found, its row could not be read, the session is neither registered nor on the herdr server, an update of the workspace is in progress (`multi-agent-mux-update`), or `--purge-conversation` found only a conversation the row does not record (a claude/grok row with no usable id; a plain stop records it, so it can be checked and then purged). **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) |
| 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable |
| 3 | `--purge-conversation` without `--yes` |
| 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain. Records preserved: the registry row and the conversation files are untouched, though the session itself may already be down |
@@ -286,6 +286,11 @@ if [ -n "$TARGET_CWD" ]; then
[ "$HERDR_ALIVE" = "1" ] && PANE_PID_NOW=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
eval "$(mam_session_ids stop --session "$SESSION_NAME" --agent "$AGENT" --cwd "$TARGET_CWD" \
--herdr-alive "$HERDR_ALIVE" --pane-pid "$PANE_PID_NOW" --purge "$PURGE")" || true
if [ "$CAPTURE_STATUS" = "purge-unrecorded" ]; then
echo "ERROR: refusing to purge $SESSION_NAME; nothing was changed: $STOP_ID_NOTE." >&2
echo " A stop without --purge-conversation records that id; check it is this agent's conversation, then purge." >&2
exit 1
fi
if [ -n "$CAPTURED_UUID" ]; then echo "captured conversation id: $CAPTURED_UUID"
else echo "WARN: no conversation id recorded before stop${STOP_ID_NOTE:+ - $STOP_ID_NOTE}"; fi
fi
+1 -1
View File
@@ -629,7 +629,7 @@
"other"
],
"passthrough": {
"1": "multi-agent-mux-stop/scripts/stop_session.sh:389"
"1": "multi-agent-mux-stop/scripts/stop_session.sh:394"
}
},
"multi-agent-mux-create/scripts/create_session.sh": {
+29
View File
@@ -622,6 +622,35 @@ def test_rev2_claude_unpinned_sibling_row_skips_creator_claimed_id(mam_sandbox,
# --------------------------------------------------------------------------- Done Criterion 6 (id_order & create/resume wait)
@pytest.mark.parametrize("recorded", [None, UB])
def test_purge_refuses_a_conversation_the_row_does_not_record(mam_sandbox, mock_herdr, mock_agents, recorded):
"""A pre-assigned row with no usable id: the folder's newest conversation may be
anyone's, so a purge refuses and changes nothing. A plain stop records it, and
the purge after that (a deliberate second step) deletes it."""
ws = mam_sandbox / "claude-guess-ws"
ws.mkdir(parents=True, exist_ok=True)
epoch = int(time.time()) - 100
transcript = _claude_transcript(mam_sandbox / ".claude" / "projects", ws, UA)
name = "claude-guess-ws-creator-claude"
_mock_herdr_agent(mock_herdr, name, ws, 5001, epoch, agent="claude")
_write_rows(mam_sandbox, [_row(name, "claude", ws, epoch, pid=5001, claude_session_id_own=recorded)])
res = _run_stop(mam_sandbox, name, "claude", purge=True)
assert res.returncode == 1, res.stdout + res.stderr
assert "refusing to purge" in res.stderr and UA in res.stderr
assert transcript.exists()
row = _read_rows(mam_sandbox)[name]
assert row["status"] == "running" and row["claude_session_id_own"] == recorded
assert name in json.loads(Path(mock_herdr).read_text())["agents"]
if recorded is None:
assert _run_stop(mam_sandbox, name, "claude").returncode == 0
assert _read_rows(mam_sandbox)[name]["claude_session_id_own"] == UA
purge = _run_stop(mam_sandbox, name, "claude", purge=True)
assert purge.returncode == 0, purge.stdout + purge.stderr
assert not transcript.exists()
def test_c6_create_waits_or_refuses_while_same_cwd_post_discovered_row_has_no_id(
mam_sandbox, mock_herdr, mock_agents
):
+99 -1
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import ast
import json
import os
import pty
import re
import shlex
import subprocess
@@ -330,6 +331,7 @@ def _run_reset(
*args: str,
stdin_text: Optional[str] = None,
env_extra: Optional[dict] = None,
tty_answer: Optional[str] = None,
) -> subprocess.CompletedProcess:
script = sandbox / ".agents" / "skills" / "multi-agent-mux-reset" / "scripts" / "reset.sh"
env = dict(
@@ -344,6 +346,22 @@ def _run_reset(
)
if env_extra:
env.update(env_extra)
if tty_answer is not None:
master, slave = pty.openpty()
try:
os.write(master, (tty_answer + "\n").encode())
return subprocess.run(
["bash", str(script), *args],
cwd=str(sandbox),
env=env,
stdin=slave,
capture_output=True,
text=True,
timeout=180,
)
finally:
os.close(master)
os.close(slave)
return subprocess.run(
["bash", str(script), *args],
cwd=str(sandbox),
@@ -778,6 +796,31 @@ def test_no_yes_non_tty_exits_3_without_touching_locks(mam_sandbox: Path, mock_h
assert not (mam_sandbox / ".mam" / "reset-spec.json").exists()
@pytest.mark.parametrize("answer,purged", [("n", False), ("", False), ("y", True)])
def test_no_yes_on_a_terminal_warns_of_the_deletion_and_asks(
mam_sandbox: Path, mock_herdr: Path, mock_agents, answer: str, purged: bool
):
"""Without --yes on a terminal the reset says the conversations are deleted for
good and asks; only an explicit yes purges.
"""
name = "reviewer-claude-01"
transcript = _claude_transcript(mam_sandbox, mam_sandbox, UA)
_mock_herdr_agent(mock_herdr, name, mam_sandbox, agent="claude", pane_id="w1:p_a")
_write_rows(mam_sandbox, [_row(name, "claude", mam_sandbox, own_id=UA)])
res = _run_reset(mam_sandbox, tty_answer=answer)
_wait_reconcile_idle(mam_sandbox)
assert res.returncode == 0, res.stdout + res.stderr
assert "permanently deletes the conversations" in res.stdout
assert "they cannot be recovered" in res.stdout
assert "Really purge and recreate 1 agents? [y/N]" in res.stderr
assert transcript.exists() is not purged
assert ("Reset cancelled; nothing was changed" in res.stdout) is not purged
assert not (mam_sandbox / ".mam" / "reset-spec.json").exists()
assert not (mam_sandbox / ".mam" / "loop-guard-active").exists()
def test_dry_run_changes_nothing_in_mam_tree(mam_sandbox: Path, mock_herdr: Path, mock_agents):
"""§5.2-1 & §5.2-4: --dry-run prints targets (with all spec fields), skipped rows,
and orchestrator_uuids, exits 0, and leaves the entire .mam tree (names, sizes,
@@ -1964,7 +2007,13 @@ def test_fence_waits_for_a_registration_and_covers_every_registry_read(tmp_path:
lock.close()
out, err = proc.communicate(timeout=30)
assert proc.returncode == 0, out + err
assert out.splitlines() == ["fenced " + os.path.realpath(p) for p in (jobs_dir, other, absent)]
assert out.splitlines() == [
"fenced " + os.path.realpath(jobs_dir),
"fenced " + os.path.realpath(other),
"created " + str(absent),
"created " + str(absent.parent),
"fenced " + os.path.realpath(absent),
]
own = (jobs_dir / ".reset-in-progress").read_text(encoding="utf-8")
assert "pid=%d\n" % holder.pid in own and "workspace=%s\n" % os.path.realpath(ws) in own
assert "sessions=" not in own
@@ -1980,6 +2029,55 @@ def test_fence_waits_for_a_registration_and_covers_every_registry_read(tmp_path:
holder.wait()
def test_stale_row_registries_neither_block_the_reset_nor_stay_behind(
mam_sandbox: Path, mock_herdr: Path, mock_agents
):
"""A row's registry that cannot be created is skipped; one the fence created is
removed at the end, unless something landed in it; an existing one stays."""
name = "reviewer-claude-01"
transcript = _claude_transcript(mam_sandbox, mam_sandbox, UA)
_mock_herdr_agent(mock_herdr, name, mam_sandbox, agent="claude", pane_id="w1:p_a")
blocker = mam_sandbox / "a-file"
blocker.write_text("x", encoding="utf-8")
gone = mam_sandbox / "gone-ws" / ".mam" / "jobs"
existing = mam_sandbox / "kept-ws" / "jobs"
existing.mkdir(parents=True)
rows = [_row(name, "claude", mam_sandbox, own_id=UA)]
for i, d in enumerate((blocker / "jobs", gone, existing)):
rows.append(_row("old-claude-%d" % i, "claude", mam_sandbox, status="stopped", own_id=None))
rows[-1]["control_plane"] = {"mam_jobs_dir": str(d)}
_write_rows(mam_sandbox, rows)
res = _run_reset(mam_sandbox, "--yes")
_wait_reconcile_idle(mam_sandbox)
assert res.returncode == 0, res.stdout + res.stderr
assert not transcript.exists()
assert not (mam_sandbox / "gone-ws").exists()
assert existing.is_dir() and not (existing / ".reset-in-progress").exists()
assert blocker.read_text(encoding="utf-8") == "x"
def test_prune_created_keeps_a_directory_with_anything_in_it(tmp_path: Path):
used = tmp_path / "used" / "jobs"
empty = tmp_path / "empty" / "jobs"
for d in (used, empty):
d.mkdir(parents=True)
(d / ".lock").write_text("", encoding="utf-8")
(used / "job1.json").write_text("{}", encoding="utf-8")
env = dict(os.environ, PYTHONPATH=str(SKILLS), PYTHONDONTWRITEBYTECODE="1", MAM_STATE_JSON="{}")
res = subprocess.run(
[sys.executable, "-m", "lib_py.reset", "prune-created", "--workspace", str(tmp_path),
str(used), str(used.parent), str(empty), str(empty.parent), str(tmp_path / "already-gone")],
env=env, capture_output=True, text=True, timeout=30,
)
assert res.returncode == 0, res.stdout + res.stderr
assert (used / "job1.json").exists() and (used / ".lock").exists()
assert not empty.parent.exists()
@pytest.mark.parametrize("artifact_cwd", ["own", "foreign"])
def test_post_discovered_id_is_purged_only_when_it_verifies_for_its_folder(
mam_sandbox: Path, mock_herdr: Path, mock_agents, monkeypatch, artifact_cwd: str