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
+201 -28

No files matched your search

@@ -154,6 +154,13 @@ def decide_stop(state: Dict[str, Any], name: str, agent: str, cwd: str,
"purge_uuid": "", "purge_uuid": "",
"note": f"{found} is also recorded by {others}; registry needs fixing", "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 { return {
"captured": found, "captured": found,
"status": "resolved" if found else "no-candidate", "status": "resolved" if found else "no-candidate",
+41 -6
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.base import AGENT_SESSION_ENV, POST_DISCOVERED
from lib_py.agents.registry import agent_of_row, get_adapter, own_key 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.delegation.registry import RESET_MARKER, reset_marker
from lib_py.update import jobs_dirs, open_jobs, row_server from lib_py.update import jobs_dirs, open_jobs, row_server
from lib_py.verify_session import verify_session_uuid 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 fenced by another live (or undecidable) reset - registries may be shared
between workspaces; its marker is left as it is. This workspace's own between workspaces; its marker is left as it is. This workspace's own
registries refuse every registration; another control plane's only those 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: 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")) 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", "") 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 [] rows = state.get("herdr_sessions") or []
sessions = ",".join("herdr:%s" % s["name"] for s in rows if s.get("name")) sessions = ",".join("herdr:%s" % s["name"] for s in rows if s.get("name"))
for d, addressed_only in jobs_dirs(workspace, state, env): for d, addressed_only in jobs_dirs(workspace, state, env):
os.makedirs(d, exist_ok=True) 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): with registry_lock(d):
held, fields = reset_marker(d) held, fields = reset_marker(d)
if held in ("live", "unknown") and fields.get("pid") != me: 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: with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(base + ("sessions=%s\n" % sessions if addressed_only else "")) fh.write(base + ("sessions=%s\n" % sessions if addressed_only else ""))
os.replace(tmp, os.path.join(d, RESET_MARKER)) 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: 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 = sub.add_parser("fence")
s.add_argument("--workspace", required=True) 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 = sub.add_parser("id-owned")
s.add_argument("--workspace", required=True) s.add_argument("--workspace", required=True)
s.add_argument("--session", required=True) s.add_argument("--session", required=True)
@@ -886,8 +917,8 @@ def main(argv: Optional[List[str]] = None) -> int:
if a.cmd == "fence": if a.cmd == "fence":
try: try:
for d in fence_registries(ws, state, os.environ): for kind, d in fence_registries(ws, state, os.environ):
print("fenced %s" % d, flush=True) print("%s %s" % (kind, d), flush=True)
except FenceBusy as exc: except FenceBusy as exc:
print("refused %s" % exc) print("refused %s" % exc)
return 1 return 1
@@ -896,6 +927,10 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1 return 1
return 0 return 0
if a.cmd == "prune-created":
prune_created(a.paths)
return 0
if a.cmd == "id-owned": if a.cmd == "id-owned":
return 0 if target_id_owned(state, a.session) else 1 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 | | 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. | | 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. | | 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`). | | 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). | | 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. | | 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 done
if [ "$DRY_RUN" != "1" ] && [ "$YES" != "1" ] && [ ! -t 0 ]; then 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 exit 3
fi fi
@@ -115,11 +115,13 @@ case "$_acquire_rc" in
*) _refuse_locked "cannot determine whether a review loop or another reset is running (ps unavailable?)" ;; *) _refuse_locked "cannot determine whether a review loop or another reset is running (ps unavailable?)" ;;
esac esac
FENCED=() FENCED=()
CREATED=()
_release_reset_lock() { _release_reset_lock() {
local d local d
for d in ${FENCED[@]+"${FENCED[@]}"}; do for d in ${FENCED[@]+"${FENCED[@]}"}; do
[ "$(_mam_lock_field "$d/.reset-in-progress" pid 2>/dev/null || true)" != "$$" ] || rm -f "$d/.reset-in-progress" [ "$(_mam_lock_field "$d/.reset-in-progress" pid 2>/dev/null || true)" != "$$" ] || rm -f "$d/.reset-in-progress"
done 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 "$RESET_MARKER" || true
mam_release_loop_lock "$MARKER" || true mam_release_loop_lock "$MARKER" || true
} }
@@ -154,6 +156,7 @@ fence_refusal=""
while IFS= read -r line; do while IFS= read -r line; do
case "$line" in case "$line" in
"fenced "*) FENCED+=("${line#fenced }") ;; "fenced "*) FENCED+=("${line#fenced }") ;;
"created "*) CREATED+=("${line#created }") ;;
"refused "*) fence_refusal="${line#refused }" ;; "refused "*) fence_refusal="${line#refused }" ;;
esac esac
done <<< "$fence_out" done <<< "$fence_out"
@@ -174,16 +177,19 @@ if [ "$pre_rc" -ne 0 ]; then
exit 1 exit 1
fi fi
_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
}
if [ -f "$SPEC_FILE" ]; then if [ -f "$SPEC_FILE" ]; then
MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset dry-run --workspace "$WS" || exit 1 MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset dry-run --workspace "$WS" || exit 1
if [ "$YES" != "1" ]; then [ "$YES" = "1" ] || _confirm_purge "Really resume the reset from $SPEC_FILE?"
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 else
PLAN_JSON="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset plan --workspace "$WS")" || exit 1 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", [])))')" 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" echo "nothing to reset"
exit 0 exit 0
fi fi
if [ "$YES" != "1" ]; then [ "$YES" = "1" ] || _confirm_purge "Really purge and recreate $TARGET_COUNT agents?"
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 printf '%s' "$PLAN_JSON" | "$PY" -m lib_py.reset write-spec --workspace "$WS" || exit 1
fi fi
+1 -1
View File
@@ -58,7 +58,7 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
| Exit | Meaning | | Exit | Meaning |
|---|---| |---|---|
| 0 | Stopped; or already `stopped` (no-op, except with `--purge-conversation`, which still purges) | | 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 | | 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable |
| 3 | `--purge-conversation` without `--yes` | | 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 | | 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) [ "$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" \ 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 --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" 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 else echo "WARN: no conversation id recorded before stop${STOP_ID_NOTE:+ - $STOP_ID_NOTE}"; fi
fi fi
+1 -1
View File
@@ -629,7 +629,7 @@
"other" "other"
], ],
"passthrough": { "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": { "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) # --------------------------------------------------------------------------- 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( def test_c6_create_waits_or_refuses_while_same_cwd_post_discovered_row_has_no_id(
mam_sandbox, mock_herdr, mock_agents mam_sandbox, mock_herdr, mock_agents
): ):
+99 -1
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import ast import ast
import json import json
import os import os
import pty
import re import re
import shlex import shlex
import subprocess import subprocess
@@ -330,6 +331,7 @@ def _run_reset(
*args: str, *args: str,
stdin_text: Optional[str] = None, stdin_text: Optional[str] = None,
env_extra: Optional[dict] = None, env_extra: Optional[dict] = None,
tty_answer: Optional[str] = None,
) -> subprocess.CompletedProcess: ) -> subprocess.CompletedProcess:
script = sandbox / ".agents" / "skills" / "multi-agent-mux-reset" / "scripts" / "reset.sh" script = sandbox / ".agents" / "skills" / "multi-agent-mux-reset" / "scripts" / "reset.sh"
env = dict( env = dict(
@@ -344,6 +346,22 @@ def _run_reset(
) )
if env_extra: if env_extra:
env.update(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( return subprocess.run(
["bash", str(script), *args], ["bash", str(script), *args],
cwd=str(sandbox), 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() 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): 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, """§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, 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() lock.close()
out, err = proc.communicate(timeout=30) out, err = proc.communicate(timeout=30)
assert proc.returncode == 0, out + err 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") 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 "pid=%d\n" % holder.pid in own and "workspace=%s\n" % os.path.realpath(ws) in own
assert "sessions=" not 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() 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"]) @pytest.mark.parametrize("artifact_cwd", ["own", "foreign"])
def test_post_discovered_id_is_purged_only_when_it_verifies_for_its_folder( 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 mam_sandbox: Path, mock_herdr: Path, mock_agents, monkeypatch, artifact_cwd: str