Files
multi-agent-mux/tests/test_issue78_reset.py

1417 lines
55 KiB
Python

"""Issue #78: multi-agent-mux-reset — unit tests for lib_py.reset and
end-to-end tests for reset.sh.
"""
from __future__ import annotations
import ast
import json
import os
import re
import shlex
import subprocess
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional
import pytest
import yaml
REPO = Path(__file__).resolve().parents[1]
SKILLS = REPO / ".agents" / "skills"
if str(SKILLS) not in sys.path:
sys.path.insert(0, str(SKILLS))
UA = "a7800001-0000-4000-8000-000000000001"
UB = "b7800002-0000-4000-8000-000000000002"
UC = "c7800003-0000-4000-8000-000000000003"
UD = "d7800004-0000-4000-8000-000000000004"
UORC = "e7800005-0000-4000-8000-000000000005"
pytestmark = pytest.mark.usefixtures("own_workspace_root")
# --------------------------------------------------------------------------- Helpers
def _row(
name: str,
agent: str,
cwd: Path,
role: str = "reviewer",
status: str = "running",
created_at: str = "2026-10-01T00:00:00Z",
server: str = "default",
label: str = "ws",
own_id: Optional[str] = None,
pid: int = 4242,
epoch: int = 1700000000,
) -> dict:
from lib_py.agents.registry import own_key
key = own_key(agent) if agent in ("claude", "agy", "codex", "grok", "hermes", "opencode") else "unknown_id"
return {
"name": name,
"status": status,
"role": role,
"agent": agent,
"herdr_session": server,
"herdr_server": server,
"herdr_workspace": label,
"herdr_session_created_at": created_at,
"herdr_session_epoch": epoch,
key: own_id,
"session_id_source": "discovered" if own_id else "pending-discovery",
"session_id_verified": bool(own_id),
"pane": {"cwd": str(cwd), "pid": pid, "cmd": agent},
}
def _run_reset_py(
args: List[str],
state: Optional[dict] = None,
stdin_text: Optional[str] = None,
env_extra: Optional[dict] = None,
):
env = dict(os.environ)
env["PYTHONPATH"] = str(SKILLS)
env["PYTHONDONTWRITEBYTECODE"] = "1"
if state is not None:
env["MAM_STATE_JSON"] = json.dumps(state)
if env_extra:
env.update(env_extra)
return subprocess.run(
[sys.executable, "-m", "lib_py.reset", *args],
env=env,
input=stdin_text,
capture_output=True,
text=True,
timeout=15,
)
def _write_rows(sandbox: Path, rows: list, orchestrator_uuids: Optional[List[str]] = None) -> Path:
lib = sandbox / ".agents" / "skills" / "lib.sh"
yaml_path = sandbox / ".mam" / "agent-sessions.yaml"
mutation = f"d['herdr_sessions'] = {repr(rows)}\n"
if orchestrator_uuids is not None:
mutation += f"d['orchestrator_uuids'] = {repr(orchestrator_uuids)}\n"
res = subprocess.run(
["bash", "-c", f'source "{lib}" && atomic_dump_yaml "{yaml_path}"'],
input=mutation,
capture_output=True,
text=True,
cwd=str(sandbox),
timeout=60,
)
assert res.returncode == 0, res.stdout + res.stderr
return yaml_path
def _read_rows(sandbox: Path) -> Dict[str, dict]:
yaml_path = sandbox / ".mam" / "agent-sessions.yaml"
data = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
return {r["name"]: r for r in data.get("herdr_sessions") or []}
def _db_session_names(sandbox: Path) -> Dict[str, str]:
import sqlite3
db_path = sandbox / ".mam" / "agent-sessions.db"
if not db_path.exists():
return {}
conn = sqlite3.connect(str(db_path))
try:
return dict(conn.execute("SELECT name, status FROM sessions").fetchall())
finally:
conn.close()
def _claude_transcript(sandbox: Path, ws: Path, uuid: str, mtime: Optional[float] = None) -> Path:
from lib_py.verify_session import workspace_key
pdir = sandbox / ".claude" / "projects" / workspace_key(str(ws))
pdir.mkdir(parents=True, exist_ok=True)
f = pdir / f"{uuid}.jsonl"
f.write_text(json.dumps({"sessionId": uuid, "cwd": str(ws), "type": "user"}) + "\n", encoding="utf-8")
if mtime is not None:
os.utime(f, (mtime, mtime))
return f
def _agy_full_artifacts(home: Path, uuid: str, mtime: Optional[float] = None) -> List[Path]:
import sqlite3
base = Path(home) / ".gemini" / "antigravity-cli"
db = base / "conversations" / f"{uuid}.db"
db.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db))
conn.execute("CREATE TABLE steps (id INTEGER)")
conn.execute("INSERT INTO steps VALUES (1)")
conn.commit()
conn.close()
if mtime is not None:
os.utime(db, (mtime, mtime))
wal = Path(str(db) + "-wal")
shm = Path(str(db) + "-shm")
wal.write_text("wal", encoding="utf-8")
shm.write_text("shm", encoding="utf-8")
brain = base / "brain" / uuid
brain.mkdir(parents=True, exist_ok=True)
(brain / "note.md").write_text("note", encoding="utf-8")
ann = base / "annotations" / f"{uuid}.pbtxt"
ann.parent.mkdir(parents=True, exist_ok=True)
ann.write_text("pbtxt", encoding="utf-8")
lock = base / "presence" / f"{uuid}.lock"
lock.parent.mkdir(parents=True, exist_ok=True)
lock.write_text("", encoding="utf-8")
return [db, wal, shm, brain, ann, lock]
def _agy_cache(home: Path, mapping: dict) -> None:
lc = Path(home) / ".gemini" / "antigravity-cli" / "cache" / "last_conversations.json"
lc.parent.mkdir(parents=True, exist_ok=True)
lc.write_text(
json.dumps({os.path.realpath(str(k)): v for k, v in mapping.items()}),
encoding="utf-8",
)
def _fake_lsof_multi(bindir: Path, mapping: dict) -> Path:
cases = "\n".join(f" *{k}*) echo {v}; found=1 ;;" for k, v in mapping.items())
exe = Path(bindir) / "lsof"
exe.write_text(
"#!/bin/sh\n"
"found=0\n"
'for a in "$@"; do\n'
' case "$a" in\n'
f"{cases}\n"
" esac\n"
"done\n"
'[ "$found" = "1" ] && exit 0\n'
"exit 1\n",
encoding="utf-8",
)
exe.chmod(0o755)
return exe
def _mock_herdr_agent(
state_file: Path,
name: str,
cwd: Path,
pid: int = 4242,
epoch: int = 1700000000,
agent: str = "claude",
pane_id: str = "",
) -> None:
state = json.loads(Path(state_file).read_text(encoding="utf-8"))
pid_str = pane_id or f"w1:p_{name}"
state.setdefault("agents", {})[name] = {
"pid": pid,
"cwd": str(cwd),
"command": agent,
"agent": agent,
"pane_id": pid_str,
"workspace_id": "w1",
"created": epoch,
"buffer": f"Ready in {cwd}\n>",
}
Path(state_file).write_text(json.dumps(state), encoding="utf-8")
def _wait_reconcile_idle(sandbox: Path, timeout: float = 30.0) -> None:
"""Wait until any background `reconcile.sh` spawned under `sandbox` finishes.
Uses `ps -eo pid=,args=` (never `pgrep -f`) so it never matches itself.
"""
sb = os.path.realpath(str(sandbox))
deadline = time.time() + timeout
while time.time() < deadline:
res = subprocess.run(
["ps", "-eo", "pid=,args="],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=5,
)
active = []
for line in (res.stdout or "").splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) < 2 or not parts[0].isdigit():
continue
pid = int(parts[0])
if pid == os.getpid():
continue
args = parts[1]
if "reconcile.sh" in args and (sb in args or str(sandbox) in args):
active.append((pid, args))
if not active:
return
time.sleep(0.2)
pytest.fail(f"reconcile.sh still running under {sandbox} after {timeout}s: {active}")
def _install_herdr_spy(sandbox: Path) -> Path:
"""Wrap sandbox/bin/herdr to log verb sequence, spec-at-first-kill, and optional
run_loop.sh probe at first agent-start.
"""
bin_dir = sandbox / "bin"
herdr_bin = bin_dir / "herdr"
herdr_real = bin_dir / "herdr.real"
if not herdr_real.exists():
herdr_bin.rename(herdr_real)
spy_log = sandbox / "herdr_spy.jsonl"
probe_file = sandbox / "herdr_spy_loop_probe.json"
wrapper = f"""#!{sys.executable}
import json, os, subprocess, sys
real = {repr(str(herdr_real))}
spy_log = {repr(str(spy_log))}
probe_file = {repr(str(probe_file))}
sandbox = {repr(str(sandbox))}
raw_args = sys.argv[1:]
args = list(raw_args)
while args and args[0] in ("-L", "--server", "-s", "--session"):
args = args[2:] if len(args) > 1 else args[1:]
if args and args[0] in ("kill-session", "has-session", "list-panes", "capture-pane", "send-keys", "ls"):
verb = args[0]
else:
verb = " ".join(args[:2]) if len(args) >= 2 else (args[0] if args else "")
rec = {{"verb": verb, "args": args}}
if verb in ("kill-session", "pane close"):
spec_p = os.path.join(sandbox, ".mam", "reset-spec.json")
rec["spec_exists"] = os.path.isfile(spec_p)
if rec["spec_exists"]:
try:
with open(spec_p, encoding="utf-8") as fh:
rec["spec"] = json.load(fh)
except Exception as exc:
rec["spec_error"] = str(exc)
if verb == "agent start" and os.environ.get("SPY_PROBE_RUN_LOOP") == "1" and not os.path.exists(probe_file):
env = dict(os.environ, MAM_LOOP_NO_FREEZE="1")
loop_sh = os.path.join(sandbox, ".agents", "skills", "multi-agent-mux-loop", "scripts", "run_loop.sh")
res = subprocess.run(
["bash", loop_sh, "--creator", "x", "--task", "t"],
cwd=sandbox,
env=env,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=30,
)
with open(probe_file, "w", encoding="utf-8") as pf:
json.dump({{"rc": res.returncode, "stdout": res.stdout, "stderr": res.stderr}}, pf)
with open(spy_log, "a", encoding="utf-8") as lf:
lf.write(json.dumps(rec) + "\\n")
os.execv(real, [real, *raw_args])
"""
herdr_bin.write_text(wrapper, encoding="utf-8")
herdr_bin.chmod(0o755)
return spy_log
def _read_spy_log(spy_log: Path) -> List[dict]:
if not spy_log.exists():
return []
return [json.loads(line) for line in spy_log.read_text(encoding="utf-8").splitlines() if line.strip()]
def _run_reset(
sandbox: Path,
*args: str,
stdin_text: Optional[str] = None,
env_extra: Optional[dict] = None,
) -> subprocess.CompletedProcess:
script = sandbox / ".agents" / "skills" / "multi-agent-mux-reset" / "scripts" / "reset.sh"
env = dict(
os.environ,
SKS_QUIESCENT_TRIES="0",
SKS_DIALOG_TIMEOUT="0",
SKS_BUSY_TIMEOUT="0",
MAM_ONBOARD_ACK_S="3",
MAM_ONBOARD_WAIT_S="3",
MAM_NO_SUBSCRIBER_DAEMON="1",
PYTHONDONTWRITEBYTECODE="1",
)
if env_extra:
env.update(env_extra)
return subprocess.run(
["bash", str(script), *args],
cwd=str(sandbox),
env=env,
input=stdin_text,
stdin=subprocess.DEVNULL if stdin_text is None else None,
capture_output=True,
text=True,
timeout=180,
)
# --------------------------------------------------------------------------- Unit tests: lib_py.reset
def test_static_checks_and_skill_version():
"""§5.2-12:
- bash -n reset.sh
- ast.parse(feature_version=(3, 9)) on lib_py/reset.py
- no `kill` command in reset.sh
- multi-agent-mux-reset/SKILL.md frontmatter version == lib.sh MAM_VERSION
"""
reset_sh = SKILLS / "multi-agent-mux-reset" / "scripts" / "reset.sh"
res = subprocess.run(["bash", "-n", str(reset_sh)], capture_output=True, text=True, timeout=10)
assert res.returncode == 0, res.stderr
src_py = (SKILLS / "lib_py" / "reset.py").read_text(encoding="utf-8")
ast.parse(src_py, filename="reset.py", feature_version=(3, 9))
src_sh = reset_sh.read_text(encoding="utf-8")
for lineno, line in enumerate(src_sh.splitlines(), 1):
stripped = line.strip()
if stripped.startswith("#"):
continue
assert not re.search(r"\bkill\b", stripped), f"reset.sh:{lineno} contains forbidden `kill`: {line}"
# Self-contained version check (no import of test_version_consistency)
lib_text = (SKILLS / "lib.sh").read_text(encoding="utf-8")
m_lib = re.search(r'^MAM_VERSION="([^"]+)"', lib_text, re.M)
assert m_lib, "MAM_VERSION not found in lib.sh"
mam_version = m_lib.group(1)
skill_text = (SKILLS / "multi-agent-mux-reset" / "SKILL.md").read_text(encoding="utf-8")
frontmatter = skill_text.split("---", 2)[1]
m_skill = re.search(r"^version:\s*(\S+)\s*$", frontmatter, re.M)
assert m_skill, "version not found in multi-agent-mux-reset/SKILL.md frontmatter"
assert m_skill.group(1) == mam_version
def test_select_targets_and_plan_cli(tmp_path: Path):
"""§5.2-1: select_targets chooses only status==running rows at realpath(WS),
resolving symlinks, listing skipped rows with reasons and orchestrator_uuids,
and refusing running control-root rows with missing agent or empty role.
"""
ws = tmp_path / "ws"
ws.mkdir()
wt = ws / "wt" / "x"
wt.mkdir(parents=True)
sym_ws = tmp_path / "sym-ws"
sym_ws.symlink_to(ws)
state = {
"orchestrator_uuids": [UORC],
"herdr_sessions": [
_row("reviewer-claude-01", "claude", ws, role="planner,reviewer", own_id=UA, server="srv1", label="lbl1"),
_row("reviewer-grok-01", "grok", sym_ws, role="reviewer", own_id=UB, server="default", label="lbl2"),
_row("creator-claude-wt", "claude", wt, role="creator", own_id=UC),
_row("stopped-codex-01", "codex", ws, status="stopped", own_id=UD),
_row("dead-hermes-01", "hermes", ws, status="terminated", own_id=None),
],
}
res = _run_reset_py(["plan", "--workspace", str(ws)], state=state)
assert res.returncode == 0, res.stderr
plan = json.loads(res.stdout)
assert [t["name"] for t in plan["targets"]] == ["reviewer-claude-01", "reviewer-grok-01"]
assert plan["targets"][0] == {
"name": "reviewer-claude-01",
"agent": "claude",
"role": "planner,reviewer",
"herdr_server": "srv1",
"herdr_workspace": "lbl1",
"old_id": UA,
"old_created_at": "2026-10-01T00:00:00Z",
}
skipped = {s["name"]: s["reason"] for s in plan["skipped"]}
assert "not the control root" in skipped["creator-claude-wt"]
assert skipped["stopped-codex-01"] == "status stopped"
assert skipped["dead-hermes-01"] == "status terminated"
assert plan["orchestrators"] == [UORC]
assert plan["errors"] == []
# Empty role or unknown agent on a running control-root row causes plan & precheck to return 1
bad_state = {
"herdr_sessions": [
_row("bad-role-claude", "claude", ws, role="", own_id=UA),
{
"name": "mystery-row",
"status": "running",
"role": "reviewer",
"agent": "not-an-agent",
"pane": {"cwd": str(ws), "pid": 123, "cmd": "bash"},
},
]
}
res_bad = _run_reset_py(["plan", "--workspace", str(ws)], state=bad_state)
assert res_bad.returncode == 1
bad_plan = json.loads(res_bad.stdout)
assert len(bad_plan["errors"]) == 2
res_pre = _run_reset_py(["precheck", "--workspace", str(ws)], state=bad_state)
assert res_pre.returncode == 1
assert "bad-role-claude" in res_pre.stdout
assert "mystery-row" in res_pre.stdout
def test_spec_file_write_read_and_validation(tmp_path: Path):
"""D5: write-spec atomically writes .mam/reset-spec.json; corrupt, wrong-version,
or wrong-workspace spec files are rejected with 'check it, then remove it by hand'.
"""
from lib_py.reset import SpecError, read_spec
ws = tmp_path / "ws"
ws.mkdir()
targets = [
{
"name": "reviewer-claude-01",
"agent": "claude",
"role": "reviewer",
"herdr_server": "default",
"herdr_workspace": "ws",
"old_id": UA,
"old_created_at": "2026-10-01T00:00:00Z",
}
]
res = _run_reset_py(
["write-spec", "--workspace", str(ws)],
stdin_text=json.dumps({"targets": targets}),
)
assert res.returncode == 0, res.stderr
spath = ws / ".mam" / "reset-spec.json"
assert spath.is_file()
doc = read_spec(str(ws))
assert doc["version"] == 1
assert doc["workspace"] == os.path.realpath(str(ws))
assert doc["agents"] == targets
# Wrong workspace
other_ws = tmp_path / "other"
other_ws.mkdir()
with pytest.raises(SpecError, match="check it, then remove it by hand"):
read_spec(str(other_ws))
# Wrong version
spath.write_text(json.dumps({"version": 99, "workspace": str(ws), "agents": []}), encoding="utf-8")
with pytest.raises(SpecError, match="check it, then remove it by hand"):
read_spec(str(ws))
# Unknown or non-canonical agent in saved spec (review P2)
for bad_agent in ("not-an-agent", "CLAUDE"):
spath.write_text(
json.dumps(
{
"version": 1,
"workspace": str(ws),
"agents": [
{
"name": "reviewer-bad-01",
"agent": bad_agent,
"role": "reviewer",
"herdr_server": "default",
"herdr_workspace": "ws",
"old_id": UA,
"old_created_at": "2026-10-01T00:00:00Z",
}
],
}
),
encoding="utf-8",
)
with pytest.raises(SpecError, match="unknown agent"):
read_spec(str(ws))
# Corrupt JSON
spath.write_text("{corrupt", encoding="utf-8")
with pytest.raises(SpecError, match="check it, then remove it by hand"):
read_spec(str(ws))
res_entries = _run_reset_py(["entries", "--workspace", str(ws)], state={})
assert res_entries.returncode == 1
assert "check it, then remove it by hand" in res_entries.stderr
def test_entries_5_states_and_resumed_row_9b(tmp_path: Path):
"""D6 1-5 & §5.2-9b:
#1 row absent -> purged
#2 same created_at (even if normal stop recorded a new id onto old_id=None) -> pending
#3 new created_at and id == old_id -> pending (resumed original conversation, 9b)
#4 new created_at and id empty -> created
#5 new created_at and id != old_id -> done
"""
from lib_py.reset import write_spec
ws = tmp_path / "ws"
ws.mkdir()
spec_agents = [
{
"name": "s1-purged",
"agent": "claude",
"role": "reviewer",
"herdr_server": "default",
"herdr_workspace": "ws",
"old_id": UA,
"old_created_at": "2026-10-01T00:00:00Z",
},
{
"name": "s2-same-inst-newly-recorded",
"agent": "agy",
"role": "reviewer",
"herdr_server": "default",
"herdr_workspace": "ws",
"old_id": None,
"old_created_at": "2026-10-01T00:00:00Z",
},
{
"name": "s3-resumed-same-id",
"agent": "claude",
"role": "reviewer",
"herdr_server": "default",
"herdr_workspace": "ws",
"old_id": UB,
"old_created_at": "2026-10-01T00:00:00Z",
},
{
"name": "s4-created-no-id",
"agent": "hermes",
"role": "reviewer",
"herdr_server": "default",
"herdr_workspace": "ws",
"old_id": UC,
"old_created_at": "2026-10-01T00:00:00Z",
},
{
"name": "s5-done",
"agent": "codex",
"role": "reviewer",
"herdr_server": "default",
"herdr_workspace": "ws",
"old_id": None,
"old_created_at": "2026-10-01T00:00:00Z",
},
]
write_spec(str(ws), spec_agents)
state = {
"herdr_sessions": [
_row(
"s2-same-inst-newly-recorded",
"agy",
ws,
status="stopped",
created_at="2026-10-01T00:00:00Z",
own_id=UA,
),
_row(
"s3-resumed-same-id",
"claude",
ws,
status="running",
created_at="2026-10-02T00:00:00Z",
own_id=UB,
),
_row(
"s4-created-no-id",
"hermes",
ws,
status="running",
created_at="2026-10-02T00:00:00Z",
own_id=None,
),
_row(
"s5-done",
"codex",
ws,
status="running",
created_at="2026-10-02T00:00:00Z",
own_id=UD,
),
]
}
res = _run_reset_py(["entries", "--workspace", str(ws)], state=state)
assert res.returncode == 0, res.stderr
parsed = {}
for line in res.stdout.strip().splitlines():
kv = dict(tok.split("=", 1) for tok in shlex.split(line))
parsed[kv["R_NAME"]] = kv
assert parsed["s1-purged"]["R_STATE"] == "purged"
assert parsed["s2-same-inst-newly-recorded"]["R_STATE"] == "pending"
assert parsed["s2-same-inst-newly-recorded"]["R_ROW_ID"] == UA
assert parsed["s3-resumed-same-id"]["R_STATE"] == "pending"
assert parsed["s4-created-no-id"]["R_STATE"] == "created"
assert parsed["s5-done"]["R_STATE"] == "done"
res_tbl = _run_reset_py(
[
"table",
"--workspace",
str(ws),
"--skipped",
"s2-same-inst-newly-recorded:purge_refused",
],
state=state,
)
assert res_tbl.returncode == 4
rows_by_name = {}
for line in res_tbl.stdout.strip().splitlines()[1:]:
cols = line.split(None, 6)
rows_by_name[cols[0]] = cols
assert rows_by_name["s2-same-inst-newly-recorded"][1:6] == ["agy", "-", "-", "no", "-"]
assert rows_by_name["s2-same-inst-newly-recorded"][6] == "skipped: purge refused"
assert rows_by_name["s3-resumed-same-id"][1:6] == ["claude", UB, UB, "yes", "no"]
assert rows_by_name["s4-created-no-id"][1:6] == ["hermes", UC, "-", "no", "-"]
assert rows_by_name["s4-created-no-id"][6] == "created; id not recorded yet"
assert rows_by_name["s5-done"][1:6] == ["codex", "-", UD, "yes", "yes"]
assert rows_by_name["s5-done"][6] == "done"
# Review F1: (a) old_id=None + same instance + id recorded on row -> record-id persists old_id=UA
from lib_py.reset import read_spec
res_rec = _run_reset_py(
["record-id", "--workspace", str(ws), "--session", "s2-same-inst-newly-recorded"],
state=state,
)
assert res_rec.returncode == 0, res_rec.stderr
spec_after = read_spec(str(ws))
s2_entry = next(e for e in spec_after["agents"] if e["name"] == "s2-same-inst-newly-recorded")
assert s2_entry["old_id"] == UA
# Review F1: (b) after resuming that skipped row (new created_at, id still UA), it is classified
# as 'pending' (NOT falsely 'done') and table exits non-zero (4) with NEW!=OLD == 'no'
state["herdr_sessions"][0] = _row(
"s2-same-inst-newly-recorded",
"agy",
ws,
status="running",
created_at="2026-10-02T00:00:00Z",
own_id=UA,
)
res_f1_entries = _run_reset_py(
["entries", "--workspace", str(ws), "--session", "s2-same-inst-newly-recorded"],
state=state,
)
assert res_f1_entries.returncode == 0
kv_f1 = dict(tok.split("=", 1) for tok in shlex.split(res_f1_entries.stdout.strip()))
assert kv_f1["R_STATE"] == "pending"
assert kv_f1["R_OLD_ID"] == UA
res_f1_tbl = _run_reset_py(["table", "--workspace", str(ws)], state=state)
assert res_f1_tbl.returncode == 4
rows_f1 = {
line.split(None, 6)[0]: line.split(None, 6)
for line in res_f1_tbl.stdout.strip().splitlines()[1:]
}
assert rows_f1["s2-same-inst-newly-recorded"][1:7] == ["agy", UA, UA, "yes", "no", "pending"]
# --------------------------------------------------------------------------- Integration tests: reset.sh
def test_no_yes_non_tty_exits_3_without_touching_locks(mam_sandbox: Path, mock_herdr: Path, mock_agents):
"""§5.2-3: Without --yes and with non-TTY stdin, reset.sh exits 3 before touching
any lock (.mam/loop-guard-active or .mam/update-admission.lock) or registry.
"""
_write_rows(mam_sandbox, [_row("reviewer-claude-01", "claude", mam_sandbox, own_id=UA)])
yaml_before = (mam_sandbox / ".mam" / "agent-sessions.yaml").read_bytes()
res = _run_reset(mam_sandbox)
assert res.returncode == 3
assert "not a terminal" in res.stderr
assert (mam_sandbox / ".mam" / "agent-sessions.yaml").read_bytes() == yaml_before
assert not (mam_sandbox / ".mam" / "loop-guard-active").exists()
assert not (mam_sandbox / ".mam" / "update-admission.lock").exists()
assert not (mam_sandbox / ".mam" / "reset-spec.json").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,
mtimes) and mock_herdr state untouched.
"""
wt = mam_sandbox / "wt" / "issue-1"
wt.mkdir(parents=True)
_write_rows(
mam_sandbox,
[
_row("reviewer-claude-01", "claude", mam_sandbox, role="planner,reviewer", server="srv-main", label="lbl-main", own_id=UA),
_row("reviewer-grok-01", "grok", mam_sandbox, role="reviewer", server="default", label="lbl-main", own_id=UB),
_row("creator-claude-wt", "claude", wt, role="creator", own_id=UC),
_row("stopped-codex-01", "codex", mam_sandbox, status="stopped", own_id=UD),
],
orchestrator_uuids=[UORC],
)
mam_dir = mam_sandbox / ".mam"
def _snapshot_mam():
return {
str(p.relative_to(mam_dir)): (p.stat().st_size, p.stat().st_mtime_ns)
for p in sorted(mam_dir.rglob("*"))
}
snap_before = _snapshot_mam()
yaml_before = (mam_dir / "agent-sessions.yaml").read_bytes()
res = _run_reset(mam_sandbox, "--dry-run")
assert res.returncode == 0, res.stdout + res.stderr
assert _snapshot_mam() == snap_before
assert (mam_dir / "agent-sessions.yaml").read_bytes() == yaml_before
out = res.stdout
assert "target: reviewer-claude-01" in out
assert "agent=claude" in out
assert "role=planner,reviewer" in out
assert "herdr_server=srv-main" in out
assert "herdr_workspace=lbl-main" in out
assert f"old_id={UA}" in out
assert "old_created_at=2026-10-01T00:00:00Z" in out
assert "target: reviewer-grok-01" in out
assert "skip: creator-claude-wt" in out and "not the control root" in out
assert "skip: stopped-codex-01 (status stopped)" in out
assert f"orchestrator {UORC} (not a row; never reset)" in out
herdr_state = json.loads(mock_herdr.read_text(encoding="utf-8"))
assert herdr_state.get("calls") == []
def test_precheck_refusals_change_nothing(mam_sandbox: Path, mock_herdr: Path, mock_agents):
"""§5.2-2: Precheck refusals (open job, live loop marker, live update marker,
unrecreatable target, caller self-target) exit 1 and change nothing; stale
markers do not refuse.
"""
tf = _claude_transcript(mam_sandbox, mam_sandbox, UA)
_mock_herdr_agent(mock_herdr, "reviewer-claude-01", mam_sandbox, agent="claude")
_write_rows(mam_sandbox, [_row("reviewer-claude-01", "claude", mam_sandbox, own_id=UA)])
spy_log = _install_herdr_spy(mam_sandbox)
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
yaml_before = yaml_path.read_bytes()
db_before = _db_session_names(mam_sandbox)
tf_before = tf.read_bytes()
def _assert_nothing_changed(expect_loop_marker: bool = False):
assert yaml_path.read_bytes() == yaml_before
assert _db_session_names(mam_sandbox) == db_before
assert tf.read_bytes() == tf_before
assert not (mam_sandbox / ".mam" / "reset-spec.json").exists()
calls = _read_spy_log(spy_log)
assert not any(c["verb"] in ("kill-session", "pane close", "agent start") for c in calls)
if not expect_loop_marker:
assert not (mam_sandbox / ".mam" / "loop-guard-active").exists()
# (a) Open job
jobs_dir = mam_sandbox / ".mam" / "jobs"
jobs_dir.mkdir(parents=True, exist_ok=True)
job_file = jobs_dir / "job78a.json"
job_file.write_text(
json.dumps({"job_id": "job78a", "agent_session": "herdr:reviewer-claude-01", "status": "running"}),
encoding="utf-8",
)
res_job = _run_reset(mam_sandbox, "--yes")
assert res_job.returncode == 1
assert "job78a" in res_job.stderr
assert "close --job job78a --request cancelled" in res_job.stderr
_assert_nothing_changed()
job_file.unlink()
# Helper to get lstart for a live process
holder = subprocess.Popen(["sleep", "300"])
try:
lstart = subprocess.run(
["ps", "-p", str(holder.pid), "-o", "lstart="],
capture_output=True,
text=True,
check=True,
).stdout.strip()
lstart = " ".join(lstart.split())
# (b) Live loop marker
loop_marker = mam_sandbox / ".mam" / "loop-guard-active"
loop_marker.write_text(f"pid={holder.pid}\nlstart={lstart}\nstarted=2026-10-29T00:00:00Z\n", encoding="utf-8")
res_loop = _run_reset(mam_sandbox, "--yes")
assert res_loop.returncode == 1
assert "loop" in res_loop.stderr
assert str(holder.pid) in res_loop.stderr
_assert_nothing_changed(expect_loop_marker=True)
loop_marker.unlink()
# (c) Live update marker
upd_marker = mam_sandbox / ".mam" / "update-in-progress"
upd_marker.write_text(f"pid={holder.pid}\nlstart={lstart}\nstarted=2026-10-29T00:00:00Z\n", encoding="utf-8")
res_upd = _run_reset(mam_sandbox, "--yes")
assert res_upd.returncode == 1
assert "update" in res_upd.stderr
_assert_nothing_changed()
upd_marker.unlink()
finally:
holder.kill()
holder.wait()
# Stale control: dead PID in loop and update markers does not refuse --dry-run
(mam_sandbox / ".mam" / "loop-guard-active").write_text("pid=999999\nlstart=Thu Jan 1 00:00:00 1970\n", encoding="utf-8")
(mam_sandbox / ".mam" / "update-in-progress").write_text("pid=999999\nlstart=Thu Jan 1 00:00:00 1970\n", encoding="utf-8")
res_stale = _run_reset(mam_sandbox, "--dry-run")
assert res_stale.returncode == 0, res_stale.stdout + res_stale.stderr
(mam_sandbox / ".mam" / "loop-guard-active").unlink()
(mam_sandbox / ".mam" / "update-in-progress").unlink()
# (d) Caller self-target (D4-4)
res_self = _run_reset(mam_sandbox, "--yes", env_extra={"CLAUDE_CODE_SESSION_ID": UA})
assert res_self.returncode == 1
assert "you are reviewer-claude-01, one of the targets" in res_self.stderr
_assert_nothing_changed()
# (e) Saved spec with valid pending target first and unknown agent second (review P2)
spec_file = mam_sandbox / ".mam" / "reset-spec.json"
bad_spec_text = json.dumps(
{
"version": 1,
"workspace": str(mam_sandbox),
"written_at": "2026-10-29T00:00:00Z",
"agents": [
{
"name": "reviewer-claude-01",
"agent": "claude",
"role": "reviewer",
"herdr_server": "default",
"herdr_workspace": "ws",
"old_id": UA,
"old_created_at": "2026-10-01T00:00:00Z",
},
{
"name": "reviewer-bad-01",
"agent": "not-an-agent",
"role": "reviewer",
"herdr_server": "default",
"herdr_workspace": "ws",
"old_id": UB,
"old_created_at": "2026-10-01T00:00:00Z",
},
],
}
)
spec_file.write_text(bad_spec_text, encoding="utf-8")
res_bad_spec = _run_reset(mam_sandbox, "--yes")
assert res_bad_spec.returncode == 1
assert "unknown agent" in res_bad_spec.stderr
assert "check it, then remove it by hand" in res_bad_spec.stderr
assert spec_file.read_text(encoding="utf-8") == bad_spec_text
spec_file.unlink()
_assert_nothing_changed()
def test_full_reset_success_spec_ordering_table_and_loop_block(
mam_sandbox: Path, mock_herdr: Path, mock_agents
):
"""§5.2-5, §5.2-6, §5.2-10:
- .mam/reset-spec.json is written atomically with both targets before the first kill call
- Both claude sessions are stopped (normal then purge, no UUID on CLI) and recreated sequentially
- Old transcripts are removed, new IDs recorded != old IDs, result table shows RECORDED=yes, NEW!=OLD=yes, done
- Spec file and loop marker are cleaned up on exit 0
- During the reset, run_loop.sh is blocked by .mam/loop-guard-active ("Another multi-agent-mux-loop is already running")
"""
name_a = "reviewer-claude-01"
name_b = "reviewer-claude-02"
tf_a = _claude_transcript(mam_sandbox, mam_sandbox, UA)
tf_b = _claude_transcript(mam_sandbox, mam_sandbox, UB)
_mock_herdr_agent(mock_herdr, name_a, mam_sandbox, agent="claude", pane_id="w1:p_a")
_mock_herdr_agent(mock_herdr, name_b, mam_sandbox, agent="claude", pane_id="w1:p_b")
_write_rows(
mam_sandbox,
[
_row(name_a, "claude", mam_sandbox, role="planner,reviewer", server="default", label="lbl-a", own_id=UA),
_row(name_b, "claude", mam_sandbox, role="reviewer", server="default", label="lbl-b", own_id=UB),
],
)
spy_log = _install_herdr_spy(mam_sandbox)
res = _run_reset(mam_sandbox, "--yes", env_extra={"SPY_PROBE_RUN_LOOP": "1"})
_wait_reconcile_idle(mam_sandbox)
assert res.returncode == 0, res.stdout + res.stderr
# Spec file & loop marker removed after completion
assert not (mam_sandbox / ".mam" / "reset-spec.json").exists()
assert not (mam_sandbox / ".mam" / "loop-guard-active").exists()
# Old transcripts purged
assert not tf_a.exists()
assert not tf_b.exists()
# Recreated rows have same name, role, herdr_server, herdr_workspace, and new IDs != old IDs
rows = _read_rows(mam_sandbox)
assert set(rows.keys()) == {name_a, name_b}
assert rows[name_a]["role"] == "planner,reviewer"
assert rows[name_a]["herdr_server"] == "default"
assert rows[name_a]["herdr_workspace"] == "lbl-a"
new_a = rows[name_a]["claude_session_id_own"]
assert new_a and new_a != UA
assert rows[name_b]["role"] == "reviewer"
assert rows[name_b]["herdr_server"] == "default"
assert rows[name_b]["herdr_workspace"] == "lbl-b"
new_b = rows[name_b]["claude_session_id_own"]
assert new_b and new_b != UB
# Result table in stdout
out_lines = [l for l in res.stdout.splitlines() if l.startswith(name_a) or l.startswith(name_b)]
assert len(out_lines) == 2
assert out_lines[0].split() == [name_a, "claude", UA, new_a, "yes", "yes", "done"]
assert out_lines[1].split() == [name_b, "claude", UB, new_b, "yes", "yes", "done"]
# Stop invoked twice per agent (normal, then --purge-conversation --yes), no UUID on CLI
stop_traces = [l for l in res.stdout.splitlines() if l.startswith("reset [") and "stop_session.sh" in l]
assert len(stop_traces) == 4
for st in stop_traces:
assert UA not in st and UB not in st
# §5.2-5 & §5.2-6: Spy log verifies spec file existed at first kill and sequential order
calls = _read_spy_log(spy_log)
first_kill = next(c for c in calls if c["verb"] in ("pane close", "kill-session"))
assert first_kill["spec_exists"] is True
assert first_kill["spec"]["version"] == 1
assert [a["name"] for a in first_kill["spec"]["agents"]] == [name_a, name_b]
# Sequential order: kill A -> start A -> kill B -> start B
events = []
for c in calls:
if c["verb"] == "kill-session":
tgt = c["args"][2] if len(c["args"]) > 2 and c["args"][1] == "-t" else c["args"][1]
if not events or events[-1] != ("kill", tgt):
events.append(("kill", tgt))
elif c["verb"] == "agent start":
events.append(("start", c["args"][2]))
assert events == [
("kill", name_a),
("start", name_a),
("kill", name_b),
("start", name_b),
]
# §5.2-10: During the reset, run_loop.sh was refused by loop-guard-active
probe_data = json.loads((mam_sandbox / "herdr_spy_loop_probe.json").read_text(encoding="utf-8"))
assert probe_data["rc"] == 1
combined_probe = probe_data["stdout"] + probe_data["stderr"]
assert "Another multi-agent-mux-loop is already running" in combined_probe
# Control after reset finishes: run_loop.sh passes the loop lock and fails at "is not registered"
loop_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
post_loop = subprocess.run(
["bash", str(loop_sh), "--creator", "x", "--task", "t"],
cwd=str(mam_sandbox),
env=dict(os.environ, MAM_LOOP_NO_FREEZE="1"),
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=30,
)
assert "is not registered" in (post_loop.stdout + post_loop.stderr)
def test_mid_run_failure_and_rerun_resumes_from_spec(
mam_sandbox: Path, mock_herdr: Path, mock_agents
):
"""§5.2-7: A (claude) succeeds, B (grok) fails at create (grok preflight fails).
Run 1 exits 1 with 'failed at create (rc 1)' and leaves .mam/reset-spec.json.
Restoring grok binary and rerunning skips A (0 stop calls for A) and only creates B.
"""
name_a = "reviewer-claude-01"
name_b = "reviewer-grok-01"
_claude_transcript(mam_sandbox, mam_sandbox, UA)
_mock_herdr_agent(mock_herdr, name_a, mam_sandbox, agent="claude", pane_id="w1:p_a")
_mock_herdr_agent(mock_herdr, name_b, mam_sandbox, agent="grok", pane_id="w1:p_b")
_write_rows(
mam_sandbox,
[
_row(name_a, "claude", mam_sandbox, own_id=UA),
_row(name_b, "grok", mam_sandbox, own_id=UB),
],
)
spy_log = _install_herdr_spy(mam_sandbox)
# Make bin/grok exit 1 (and XAI_API_KEY="") so B's create_session.sh preflight fails with exit 1
grok_bin = mam_sandbox / "bin" / "grok"
grok_bin.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
grok_bin.chmod(0o755)
res1 = _run_reset(mam_sandbox, "--yes", env_extra={"XAI_API_KEY": ""})
_wait_reconcile_idle(mam_sandbox)
assert res1.returncode == 1
assert (mam_sandbox / ".mam" / "reset-spec.json").is_file()
assert "failed at create (rc 1)" in res1.stdout
rows1 = _read_rows(mam_sandbox)
assert name_a in rows1 and rows1[name_a]["claude_session_id_own"] != UA
assert name_b not in rows1 # B was purged before its create failed
# Restore grok binary, clear spy log, and rerun
grok_bin.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
grok_bin.chmod(0o755)
spy_log.unlink()
res2 = _run_reset(mam_sandbox, "--yes", env_extra={"XAI_API_KEY": ""})
_wait_reconcile_idle(mam_sandbox)
assert res2.returncode == 0, res2.stdout + res2.stderr
assert not (mam_sandbox / ".mam" / "reset-spec.json").exists()
# Spy confirms A had 0 stop/kill/start calls on rerun, and B was only created
calls2 = _read_spy_log(spy_log)
assert not any(c["verb"] in ("pane close", "kill-session") for c in calls2)
starts2 = [c["args"][2] for c in calls2 if c["verb"] == "agent start"]
assert starts2 == [name_b]
assert f"reset [{name_a}]:" not in res2.stdout
def test_purge_refused_exit_4_preserves_stopped_row_and_rerun_completes(
mam_sandbox: Path, mock_herdr: Path, mock_agents
):
"""§5.2-8: A's transcript is held open by a real sleep 300 process (via fake lsof).
Run 1: A is stopped in step 1, refused at purge in step 3 (exit 4 -> skipped: purge refused),
prints holder PID and 'sleep 300', keeps A's row (status=stopped, own_id=UA) and transcript,
leaves sleep alive, completes B, and exits 4 with spec file intact.
Run 2 (after holder ends): A is purged and recreated, B is untouched, exits 0.
"""
name_a = "reviewer-claude-01"
name_b = "reviewer-claude-02"
tf_a = _claude_transcript(mam_sandbox, mam_sandbox, UA)
tf_b = _claude_transcript(mam_sandbox, mam_sandbox, UB)
_mock_herdr_agent(mock_herdr, name_a, mam_sandbox, agent="claude", pane_id="w1:p_a")
_mock_herdr_agent(mock_herdr, name_b, mam_sandbox, agent="claude", pane_id="w1:p_b")
_write_rows(
mam_sandbox,
[
_row(name_a, "claude", mam_sandbox, own_id=UA),
_row(name_b, "claude", mam_sandbox, own_id=UB),
],
)
spy_log = _install_herdr_spy(mam_sandbox)
holder = subprocess.Popen(["sleep", "300"])
try:
lsof_exe = _fake_lsof_multi(mam_sandbox / "bin", {UA: holder.pid})
res1 = _run_reset(mam_sandbox, "--yes")
_wait_reconcile_idle(mam_sandbox)
assert res1.returncode == 4, res1.stdout + res1.stderr
combined1 = res1.stdout + res1.stderr
assert f"pid {holder.pid}" in combined1
assert "sleep 300" in combined1
assert "skipped: purge refused" in res1.stdout
assert (mam_sandbox / ".mam" / "reset-spec.json").is_file()
# Holder process is still alive (reset never kills external processes)
assert holder.poll() is None
# A's row is stopped with UA preserved and transcript intact; B is done
rows1 = _read_rows(mam_sandbox)
assert rows1[name_a]["status"] == "stopped"
assert rows1[name_a]["claude_session_id_own"] == UA
assert tf_a.is_file()
assert not tf_b.exists()
assert rows1[name_b]["claude_session_id_own"] != UB
# Release holder and rerun
lsof_exe.unlink()
finally:
holder.kill()
holder.wait()
spy_log.unlink()
res2 = _run_reset(mam_sandbox, "--yes")
_wait_reconcile_idle(mam_sandbox)
assert res2.returncode == 0, res2.stdout + res2.stderr
assert not (mam_sandbox / ".mam" / "reset-spec.json").exists()
assert not tf_a.exists()
rows2 = _read_rows(mam_sandbox)
assert rows2[name_a]["claude_session_id_own"] != UA
assert f"reset [{name_b}]:" not in res2.stdout
def test_post_discovered_without_recorded_id_8b_and_exit_5(
mam_sandbox: Path, mock_herdr: Path, mock_agents
):
"""§5.2-8b (i) & (ii) and §5.2-9:
- 8b(i): agy row has no recorded own_id while herdr is live and cache points to UA.
Run 1 with UA held by fake lsof: step 1 captures UA onto the row (status=stopped),
step 3 refuses purge (exit 4).
Run 2 after releasing lsof: step 3 purges UA (all UA artifacts deleted!) and recreates
the agy session. Because mock agy does not record a new ID, exits 5 (created).
Run 3 (while still without ID): 0 stop calls (does not re-purge the new session!), exits 5.
Run 4 (after recording new ID UB): exits 0, removes spec file, table shows OLD_ID='-' and NEW!=OLD='yes'.
- 8b(ii): agy row has no recorded own_id and no conversation on disk -> step 2 skips with
'skipped: no conversation id recorded' (exit 4), keeping the stopped row without calling purge.
"""
name_a = "reviewer-agy-01"
now = time.time()
epoch = int(now) - 100
a_files = _agy_full_artifacts(mam_sandbox, UA, mtime=now)
_agy_cache(mam_sandbox, {mam_sandbox: UA})
holder = subprocess.Popen(["sleep", "300"])
try:
lsof_exe = _fake_lsof_multi(mam_sandbox / "bin", {UA: holder.pid})
_mock_herdr_agent(mock_herdr, name_a, mam_sandbox, pid=holder.pid, epoch=epoch, agent="agy", pane_id="w1:p_a")
_write_rows(
mam_sandbox,
[_row(name_a, "agy", mam_sandbox, own_id=None, pid=holder.pid, epoch=epoch)],
)
spy_log = _install_herdr_spy(mam_sandbox)
# Run 1: step 1 captures UA onto the row and record-id persists UA into reset-spec.json;
# step 3 refuses purge because lsof holds UA
res1 = _run_reset(mam_sandbox, "--yes")
_wait_reconcile_idle(mam_sandbox)
assert res1.returncode == 4, res1.stdout + res1.stderr
rows1 = _read_rows(mam_sandbox)
assert rows1[name_a]["status"] == "stopped"
assert rows1[name_a]["agy_conversation_id_own"] == UA
assert a_files[0].exists()
spec1 = json.loads((mam_sandbox / ".mam" / "reset-spec.json").read_text(encoding="utf-8"))
assert spec1["agents"][0]["old_id"] == UA
# Simulate resuming the skipped session (new created_at, own_id still UA) before Run 2 (review F1):
# because record-id persisted old_id=UA, Run 2 still treats it as 'pending' and purges UA
rows1[name_a]["status"] = "running"
rows1[name_a]["herdr_session_created_at"] = "2026-10-02T00:00:00Z"
_write_rows(mam_sandbox, [rows1[name_a]])
_mock_herdr_agent(mock_herdr, name_a, mam_sandbox, pid=holder.pid, epoch=epoch + 10, agent="agy", pane_id="w1:p_a")
# Release lsof and Run 2: step 3 purges UA (deletes all UA artifacts!) and creates new agy
lsof_exe.unlink()
finally:
holder.kill()
holder.wait()
res2 = _run_reset(mam_sandbox, "--yes")
_wait_reconcile_idle(mam_sandbox)
assert res2.returncode == 5, res2.stdout + res2.stderr
assert "created; id not recorded yet" in res2.stdout
for p in a_files:
assert not p.exists(), f"UA artifact should have been purged on rerun: {p}"
assert (mam_sandbox / ".mam" / "reset-spec.json").is_file()
# Run 3 (§5.2-9): rerunning while in `created` state performs 0 stop/create calls and exits 5
spy_log.unlink()
res3 = _run_reset(mam_sandbox, "--yes")
_wait_reconcile_idle(mam_sandbox)
assert res3.returncode == 5
assert _read_spy_log(spy_log) == []
assert f"reset [{name_a}]:" not in res3.stdout
# Record new ID UB onto the recreated row and Run 4 -> exits 0, OLD_ID=UA, NEW!=OLD='yes'
rows3 = _read_rows(mam_sandbox)
rows3[name_a]["agy_conversation_id_own"] = UB
_write_rows(mam_sandbox, [rows3[name_a]])
res4 = _run_reset(mam_sandbox, "--yes")
_wait_reconcile_idle(mam_sandbox)
assert res4.returncode == 0, res4.stdout + res4.stderr
assert not (mam_sandbox / ".mam" / "reset-spec.json").exists()
tbl_line = [l for l in res4.stdout.splitlines() if l.startswith(name_a)][0]
assert tbl_line.split() == [name_a, "agy", UA, UB, "yes", "yes", "done"]
def test_post_discovered_uncapturable_id_8b_ii(
mam_sandbox: Path, mock_herdr: Path, mock_agents
):
"""§5.2-8b(ii): agy row has no recorded own_id and no conversation on disk -> step 2 skips
with 'skipped: no conversation id recorded' (exit 4), keeping the stopped row without
calling purge; manual purge followed by rerun recreates via D6 #1 (purged).
"""
name_b = "reviewer-agy-02"
epoch = int(time.time()) - 100
_mock_herdr_agent(mock_herdr, name_b, mam_sandbox, epoch=epoch, agent="agy", pane_id="w1:p_b")
_write_rows(mam_sandbox, [_row(name_b, "agy", mam_sandbox, own_id=None, epoch=epoch)])
spy_log = _install_herdr_spy(mam_sandbox)
res_ii = _run_reset(mam_sandbox, "--yes")
_wait_reconcile_idle(mam_sandbox)
assert res_ii.returncode == 4, res_ii.stdout + res_ii.stderr
assert "skipped: no conversation id recorded" in res_ii.stdout
assert not any(
l.startswith("reset [") and "--purge-conversation" in l for l in res_ii.stdout.splitlines()
)
rows_ii = _read_rows(mam_sandbox)
assert name_b in rows_ii and rows_ii[name_b]["status"] == "stopped"
# Manual purge drops the row; rerunning reset then sees D6 #1 (purged) and runs create only
stop_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res_manual = subprocess.run(
["bash", str(stop_sh), "--session", name_b, "--agent", "agy", "--purge-conversation", "--yes"],
cwd=str(mam_sandbox),
env=dict(os.environ, SKS_QUIESCENT_TRIES="0", SKS_DIALOG_TIMEOUT="0", SKS_BUSY_TIMEOUT="0"),
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=60,
)
assert res_manual.returncode == 0, res_manual.stdout + res_manual.stderr
assert name_b not in _read_rows(mam_sandbox)
spy_log.unlink()
res_ii_rerun = _run_reset(mam_sandbox, "--yes")
_wait_reconcile_idle(mam_sandbox)
assert res_ii_rerun.returncode == 5, res_ii_rerun.stdout + res_ii_rerun.stderr
calls_ii = _read_spy_log(spy_log)
assert not any(c["verb"] in ("pane close", "kill-session") for c in calls_ii)
assert [c["args"][2] for c in calls_ii if c["verb"] == "agent start"] == [name_b]
assert name_b in _read_rows(mam_sandbox)
# Recording new ID UB when old_id remained None produces OLD_ID='-' and NEW!=OLD='yes'
rows_ii_after = _read_rows(mam_sandbox)
rows_ii_after[name_b]["agy_conversation_id_own"] = UB
_write_rows(mam_sandbox, [rows_ii_after[name_b]])
res_ii_done = _run_reset(mam_sandbox, "--yes")
_wait_reconcile_idle(mam_sandbox)
assert res_ii_done.returncode == 0, res_ii_done.stdout + res_ii_done.stderr
assert not (mam_sandbox / ".mam" / "reset-spec.json").exists()
tbl_ii = [l for l in res_ii_done.stdout.splitlines() if l.startswith(name_b)][0]
assert tbl_ii.split() == [name_b, "agy", "-", UB, "yes", "yes", "done"]
@pytest.mark.parametrize(
"sig_name,expected_rc",
[("SIGTERM", 143), ("SIGINT", 130), ("SIGHUP", 129)],
)
def test_signal_interrupts_reset_preserves_spec_and_holds_lock_until_child_exits(
mam_sandbox: Path, mock_herdr: Path, mock_agents, sig_name: str, expected_rc: int
):
"""Review P1 / N1:
When SIGTERM, SIGINT, or SIGHUP arrives while an in-flight stop/create child is running:
- reset.sh keeps holding .mam/loop-guard-active (so a competing run_loop.sh is still refused)
until the in-flight child finishes;
- once the child finishes, reset.sh immediately exits with the signal's nonzero code (143/130/129),
releasing .mam/loop-guard-active on exit, preserving .mam/reset-spec.json, and NOT continuing
to purge/create the current or subsequent targets.
"""
import signal
sig = getattr(signal, sig_name)
name_a = "reviewer-claude-01"
name_b = "reviewer-claude-02"
tf_a = _claude_transcript(mam_sandbox, mam_sandbox, UA)
tf_b = _claude_transcript(mam_sandbox, mam_sandbox, UB)
_mock_herdr_agent(mock_herdr, name_a, mam_sandbox, agent="claude", pane_id="w1:p_a")
_mock_herdr_agent(mock_herdr, name_b, mam_sandbox, agent="claude", pane_id="w1:p_b")
_write_rows(
mam_sandbox,
[
_row(name_a, "claude", mam_sandbox, own_id=UA),
_row(name_b, "claude", mam_sandbox, own_id=UB),
],
)
# Wrap bin/herdr so the first kill-session blocks until release_gate is created
bin_dir = mam_sandbox / "bin"
herdr_bin = bin_dir / "herdr"
herdr_real = bin_dir / "herdr.real"
if not herdr_real.exists():
herdr_bin.rename(herdr_real)
entered_gate = mam_sandbox / "child_entered"
release_gate = mam_sandbox / "child_release"
spy_log = mam_sandbox / "herdr_spy.jsonl"
herdr_bin.write_text(
f"""#!{sys.executable}
import json, os, sys, time
real = {repr(str(herdr_real))}
entered = {repr(str(entered_gate))}
release = {repr(str(release_gate))}
spy_log = {repr(str(spy_log))}
raw_args = sys.argv[1:]
args = list(raw_args)
while args and args[0] in ("-L", "--server", "-s", "--session"):
args = args[2:] if len(args) > 1 else args[1:]
verb = args[0] if (args and args[0] in ("kill-session", "has-session", "list-panes", "capture-pane", "send-keys", "ls")) else (" ".join(args[:2]) if len(args) >= 2 else "")
with open(spy_log, "a", encoding="utf-8") as lf:
lf.write(json.dumps({{"verb": verb, "args": args}}) + "\\n")
if verb == "kill-session" and not os.path.exists(entered):
with open(entered, "w", encoding="utf-8") as fh:
fh.write("1")
deadline = time.time() + 15.0
while not os.path.exists(release) and time.time() < deadline:
time.sleep(0.05)
os.execv(real, [real, *raw_args])
""",
encoding="utf-8",
)
herdr_bin.chmod(0o755)
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-reset" / "scripts" / "reset.sh"
env = dict(
os.environ,
SKS_QUIESCENT_TRIES="0",
SKS_DIALOG_TIMEOUT="0",
SKS_BUSY_TIMEOUT="0",
MAM_ONBOARD_ACK_S="3",
MAM_ONBOARD_WAIT_S="3",
MAM_NO_SUBSCRIBER_DAEMON="1",
PYTHONDONTWRITEBYTECODE="1",
)
proc = subprocess.Popen(
["bash", str(script), "--yes"],
cwd=str(mam_sandbox),
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
try:
deadline = time.time() + 15.0
while not entered_gate.exists() and time.time() < deadline:
assert proc.poll() is None, "reset.sh exited before reaching kill-session"
time.sleep(0.05)
assert entered_gate.exists(), "timed out waiting for kill-session child"
# Signal the reset parent while the foreground child is still blocked
proc.send_signal(sig)
time.sleep(0.2)
# While the child is still running, reset.sh has not exited and still holds the loop lock
assert proc.poll() is None
assert (mam_sandbox / ".mam" / "loop-guard-active").is_file()
if sig_name == "SIGTERM":
loop_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
competing = subprocess.run(
["bash", str(loop_sh), "--creator", "x", "--task", "t"],
cwd=str(mam_sandbox),
env=dict(os.environ, MAM_LOOP_NO_FREEZE="1"),
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=20,
)
assert competing.returncode == 1
assert "Another multi-agent-mux-loop is already running" in (competing.stdout + competing.stderr)
# Release the child and wait for reset.sh to terminate
release_gate.write_text("1", encoding="utf-8")
stdout, stderr = proc.communicate(timeout=30)
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
_wait_reconcile_idle(mam_sandbox)
assert proc.returncode == expected_rc, f"rc={proc.returncode}, stdout={stdout}, stderr={stderr}"
assert not (mam_sandbox / ".mam" / "loop-guard-active").exists()
assert (mam_sandbox / ".mam" / "reset-spec.json").is_file()
# Neither transcript was purged, and no create or second-target operation ran
assert tf_a.is_file()
assert tf_b.is_file()
calls = _read_spy_log(spy_log)
assert not any(c["verb"] == "agent start" for c in calls)
assert f"reset [{name_b}]:" not in stdout
assert not any(
l.startswith("reset [") and "--purge-conversation" in l for l in stdout.splitlines()
)