From 022c656052aae1c81f34c43f44ebca5ba0be3c4f Mon Sep 17 00:00:00 2001 From: Godopu Date: Sat, 31 Oct 2026 07:56:07 +0900 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- .agents/skills/lib_py/agents/session_ids.py | 7 ++ .agents/skills/lib_py/reset.py | 47 ++++++-- .agents/skills/multi-agent-mux-reset/SKILL.md | 4 +- .../multi-agent-mux-reset/scripts/reset.sh | 33 +++--- .agents/skills/multi-agent-mux-stop/SKILL.md | 2 +- .../scripts/stop_session.sh | 5 + tests/data/skill_code_blocks.json | 2 +- tests/test_issue63_same_cwd_ids.py | 29 +++++ tests/test_issue78_reset.py | 100 +++++++++++++++++- 9 files changed, 201 insertions(+), 28 deletions(-) diff --git a/.agents/skills/lib_py/agents/session_ids.py b/.agents/skills/lib_py/agents/session_ids.py index 3d1b33e..4e82ab2 100644 --- a/.agents/skills/lib_py/agents/session_ids.py +++ b/.agents/skills/lib_py/agents/session_ids.py @@ -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", diff --git a/.agents/skills/lib_py/reset.py b/.agents/skills/lib_py/reset.py index 401752a..409bc46 100644 --- a/.agents/skills/lib_py/reset.py +++ b/.agents/skills/lib_py/reset.py @@ -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): - 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): 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 diff --git a/.agents/skills/multi-agent-mux-reset/SKILL.md b/.agents/skills/multi-agent-mux-reset/SKILL.md index 5d14c7e..2084dd2 100644 --- a/.agents/skills/multi-agent-mux-reset/SKILL.md +++ b/.agents/skills/multi-agent-mux-reset/SKILL.md @@ -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. | diff --git a/.agents/skills/multi-agent-mux-reset/scripts/reset.sh b/.agents/skills/multi-agent-mux-reset/scripts/reset.sh index 9cd1ad8..d40d143 100755 --- a/.agents/skills/multi-agent-mux-reset/scripts/reset.sh +++ b/.agents/skills/multi-agent-mux-reset/scripts/reset.sh @@ -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 +_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 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 + [ "$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 diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index 606a07d..327e7cd 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -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 | diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index edeaed6..5f7086d 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -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 diff --git a/tests/data/skill_code_blocks.json b/tests/data/skill_code_blocks.json index 116a8cd..65b9c21 100644 --- a/tests/data/skill_code_blocks.json +++ b/tests/data/skill_code_blocks.json @@ -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": { diff --git a/tests/test_issue63_same_cwd_ids.py b/tests/test_issue63_same_cwd_ids.py index 89a815c..ab792a0 100644 --- a/tests/test_issue63_same_cwd_ids.py +++ b/tests/test_issue63_same_cwd_ids.py @@ -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 ): diff --git a/tests/test_issue78_reset.py b/tests/test_issue78_reset.py index 010e99b..ef4d358 100644 --- a/tests/test_issue78_reset.py +++ b/tests/test_issue78_reset.py @@ -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