fix(stop): record and purge only the row's own or a verified id; a dead herdr records none (Issue #63)

This commit is contained in:
Godopu committed 2026-10-21 16:28:34 +00:00
1 parent b8a30d5b93
commit 3a2219454e
2 files changed
+178 -20

No files matched your search

+141
View File
@@ -248,3 +248,144 @@ def test_session_ids_own_id_priority_rules_unit(tmp_path):
s_stale = decide_stop(state_stale, "ws-creator-agy", "agy", str(ws), False, purge=False, home=str(home))
assert s_stale["captured"] == "" and "no longer verifies; kept as is" in s_stale["note"]
# --------------------------------------------------------------------------- Done Criteria 1 & 2 (stop & purge)
def _fake_lsof_multi(bindir: Path, mapping: dict) -> None:
"""Fake lsof that prints the pid from `mapping` ({uuid_substr: pid}) for any matching 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"
)
exe.chmod(0o755)
def _mock_herdr_agent(state_file: Path, name: str, cwd: Path, pid: int, epoch: int,
agent: str = "agy", pane_id: str = "") -> None:
state = json.loads(Path(state_file).read_text())
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"Antigravity {cwd}\n>",
}
Path(state_file).write_text(json.dumps(state))
def _write_rows(sandbox: Path, rows: list) -> Path:
lib = sandbox / ".agents" / "skills" / "lib.sh"
yaml_path = sandbox / ".mam" / "agent-sessions.yaml"
mutation = f"d['herdr_sessions'] = {repr(rows)}\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:
yaml_path = sandbox / ".mam" / "agent-sessions.yaml"
data = yaml.safe_load(yaml_path.read_text()) or {}
return {r["name"]: r for r in data.get("herdr_sessions") or []}
def _run_stop(sandbox: Path, session: str, agent: str = "agy", purge: bool = False) -> subprocess.CompletedProcess:
script = sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
cmd = ["bash", str(script), "--session", session, "--agent", agent]
if purge:
cmd += ["--purge-conversation", "--yes"]
env = dict(os.environ, SKS_QUIESCENT_TRIES="0", SKS_DIALOG_TIMEOUT="0", SKS_BUSY_TIMEOUT="0")
return subprocess.run(cmd, capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=120)
@pytest.mark.parametrize("order", [("a_then_b", True), ("b_then_a", True), ("both_dead", False)])
def test_c1_stop_same_cwd_agy_sessions_records_only_verified_owner(mam_sandbox, mock_herdr, order):
"""Done Criterion 1: two agy sessions in one cwd; cache points to A's UA.
Stop A->B and B->A with herdr alive records UA on A and None on B; when
herdr is already dead for both, neither records an id and both leave a reason."""
label, alive = order
ws = mam_sandbox / "same-ws"
ws.mkdir(parents=True, exist_ok=True)
now = time.time()
epoch = int(now) - 100
_agy_full_artifacts(mam_sandbox, UA)
_agy_full_artifacts(mam_sandbox, UB)
_agy_cache(mam_sandbox, {ws: UA})
proc_a = subprocess.Popen(["sleep", "120"])
proc_b = subprocess.Popen(["sleep", "120"])
try:
_fake_lsof_multi(mam_sandbox / "bin", {UA: proc_a.pid, UB: proc_b.pid})
name_a, name_b = "same-ws-creator-agy", "same-ws-reviewer-agy"
_write_rows(mam_sandbox, [
_row(name_a, "agy", ws, epoch, pid=proc_a.pid),
_row(name_b, "agy", ws, epoch, pid=proc_b.pid),
])
if alive:
_mock_herdr_agent(mock_herdr, name_a, ws, proc_a.pid, epoch, pane_id="w1:p_a")
_mock_herdr_agent(mock_herdr, name_b, ws, proc_b.pid, epoch, pane_id="w1:p_b")
seq = [name_a, name_b] if label != "b_then_a" else [name_b, name_a]
for sname in seq:
res = _run_stop(mam_sandbox, sname, "agy")
assert res.returncode == 0, res.stdout + res.stderr
rows = _read_rows(mam_sandbox)
if alive:
assert rows[name_a]["agy_conversation_id_own"] == UA
assert rows[name_a]["resumable"] is True
assert rows[name_b]["agy_conversation_id_own"] is None
assert rows[name_b]["last_visible_status"].startswith("stop: ")
else:
assert rows[name_a]["agy_conversation_id_own"] is None
assert rows[name_b]["agy_conversation_id_own"] is None
assert "herdr session was already gone" in rows[name_a]["last_visible_status"]
assert "herdr session was already gone" in rows[name_b]["last_visible_status"]
finally:
for p in (proc_a, proc_b):
p.kill()
p.wait()
@pytest.mark.parametrize("broken_dup", [False, True])
def test_c2_purge_never_deletes_another_rows_conversation(mam_sandbox, mock_herdr, broken_dup):
"""Done Criterion 2: A is stopped with UA; B has no own id (or also has UA in a
broken registry) and herdr is dead. Purging B leaves A's conversation intact."""
ws = mam_sandbox / "same-ws"
ws.mkdir(parents=True, exist_ok=True)
epoch = int(time.time()) - 100
a_files = _agy_full_artifacts(mam_sandbox, UA)
_agy_cache(mam_sandbox, {ws: UA})
name_a, name_b = "same-ws-creator-agy", "same-ws-reviewer-agy"
row_a = _row(name_a, "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA,
session_id_source="discovered", session_id_verified=True)
row_b = _row(name_b, "agy", ws, epoch, status="running",
agy_conversation_id_own=(UA if broken_dup else None))
_write_rows(mam_sandbox, [row_a, row_b])
res = _run_stop(mam_sandbox, name_b, "agy", purge=True)
assert res.returncode == 0, res.stdout + res.stderr
assert "nothing purged" in res.stdout
for p in a_files:
assert p.exists(), f"A's artifact was deleted by B's purge: {p}"
rows = _read_rows(mam_sandbox)
assert name_b not in rows
assert rows[name_a]["agy_conversation_id_own"] == UA