Files
multi-agent-mux/tests/test_issue10_13_purge.py
T
GodopuandClaude Fable 5.1 fae70c13d9 fix(issue-10,13): check every agy artifact for holders and purge orphaned leftovers
Round-3 review of f5a6575 (planner-reviewer-codex-01, R1/R2).

R1 - the adapter's in-use check handed only the conversation DB to lsof, so a
holder that keeps the presence lock (or a sidecar) open while naming the uuid
on no command line and in no session variable was invisible, and the held
lock was unlinked from under it. AgyAgentAdapter.conversation_in_use now
passes every path of the conversation (DB, -wal/-shm/-journal, brain,
annotation, presence lock).

R2 - both resolver stages required the DB, so once an interrupted or pre-#10
purge had removed it, the WAL/annotation/lock left behind were "no artifact"
(exit 5) forever. BaseAgentAdapter.has_artifacts (default: a uuid-bearing
artifact path that exists) replaces the resolver's path fallback, and agy's
override accepts any of its pieces.

Tests: a flock holder that names the uuid nowhere is refused and nothing is
removed; orphaned leftovers resolve to agy and are purged. VERSIONS.md
v4.4.3 items 1-2 and orc-onboard SKILL.md updated.

Refs #10 #13

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-13 10:30:02 +09:00

434 lines
18 KiB
Python

#!/usr/bin/env python3
"""Issues #10 and #13 — purging a conversation must leave nothing resumable
behind, and an orchestrator conversation (no herdr_sessions row) must be
purgeable through orc_onboard.sh.
#10: the agy adapter deleted only the conversation DB and the brain dir; the
SQLite sidecars (-wal/-shm/-journal), the title annotation and the presence
lock survived. The lock is removed only once nothing references the
conversation any more.
#13: `orc_onboard.sh --remove <uuid> --purge-conversation --yes` resolves the
adapter from what is on disk (orchestrator_uuids stores bare UUIDs), purges
through it, and refuses while a live process still names the UUID -
including the orchestrator's own session running the command.
Every test gets fresh conversation ids (the `conv` / `other` fixtures):
conversation_in_use scans every process on the host, so a fixed id in this
file is named by anything that carries the file's text on its command line.
"""
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import sys
import time
import uuid as uuidlib
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
ONBOARD_SH = SKILLS / "multi-agent-mux-orc-onboard" / "scripts" / "orc_onboard.sh"
sys.path.insert(0, str(SKILLS))
from lib_py.agents.registry import get_adapter # noqa: E402
from lib_py.agents.base import DiscoveryContext, conversation_in_use # noqa: E402
# The session-id variables an agent CLI exports to its tool shells. Scrubbed
# from every subprocess env below so the caller's own session never leaks in.
SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID",
"HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID")
def _processes_naming(ident: str) -> list:
out = subprocess.run(["ps", "-axo", "pid=,command="], capture_output=True, text=True, timeout=10).stdout
return [line.strip()[:120] for line in out.splitlines() if ident in line]
def _fresh_id() -> str:
"""A conversation id no other process can already be naming. With fixed
ids, the delegation wrapper of a review job - whose --prompt carried this
file's diff - named them, and five tests read "in use" during review."""
ident = str(uuidlib.uuid4())
named = _processes_naming(ident)
assert not named, f"fresh id {ident} is already named by a live process: {named}"
return ident
@pytest.fixture
def conv() -> str:
return _fresh_id()
@pytest.fixture
def other() -> str:
return _fresh_id()
def _clean_env(extra=None) -> dict:
env = dict(os.environ)
for k in SESSION_ENV + ("MAM_ORCHESTRATOR_UUIDS",):
env.pop(k, None)
env["PYTHONPATH"] = f"{SKILLS}:{env.get('PYTHONPATH', '')}"
if extra:
env.update(extra)
return env
def _agy_tree(home: Path, uuid: str) -> dict:
root = home / ".gemini" / "antigravity-cli"
paths = {
"db": root / "conversations" / f"{uuid}.db",
"wal": root / "conversations" / f"{uuid}.db-wal",
"shm": root / "conversations" / f"{uuid}.db-shm",
"brain": root / "brain" / uuid,
"annotation": root / "annotations" / f"{uuid}.pbtxt",
"lock": root / "presence" / f"{uuid}.lock",
}
for key, p in paths.items():
if key == "brain":
p.mkdir(parents=True, exist_ok=True)
(p / "note.txt").write_text("brain")
elif key == "db":
# A real conversation DB: the adapter's verify_artifact opens it
# with sqlite3, and sqlite discards a garbage -wal next to a
# garbage main file, which would hide the sidecar from the purge.
p.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(p)
conn.execute("CREATE TABLE steps (id INTEGER)")
conn.execute("INSERT INTO steps VALUES (1)")
conn.commit()
conn.close()
else:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("x" if key != "lock" else "")
return paths
def _holder(uuid: str) -> subprocess.Popen:
"""A live process whose command line names the conversation, the way a
resumed `agy --conversation <uuid>` or `claude -r <uuid>` does. (Not
`sh -c 'sleep …'`: a shell execs its last simple command and the uuid
would vanish from the process list.)"""
proc = subprocess.Popen([sys.executable, "-c", "import sys, time; time.sleep(60)", "--conversation", uuid])
time.sleep(0.3)
return proc
def _stop(proc: subprocess.Popen) -> None:
proc.kill()
proc.wait(timeout=10)
def _fake_agent_cli(tmp_path: Path, name: str) -> Path:
"""An executable named like an agent CLI. `<name> <flag> <uuid> -- cmd…`
runs cmd as its child (not exec: this process stays in the tree with the
uuid on its command line) - the agent session a skill runs under."""
bindir = tmp_path / "agent-bin"
bindir.mkdir(exist_ok=True)
cli = bindir / name
cli.write_text('#!/bin/sh\nshift 3\n"$@"\nexit $?\n')
cli.chmod(0o755)
return cli
def _run_onboard(args, sandbox: Path, extra_env=None) -> subprocess.CompletedProcess:
env = _clean_env()
env.update({
"AGENT_SESSIONS_YAML": str(sandbox / ".mam" / "agent-sessions.yaml"),
"YAML_PATH": str(sandbox / ".mam" / "agent-sessions.yaml"),
"HOME_DIR": str(sandbox),
"AGENT_PYTHON_BIN": sys.executable,
"DELEGATE_JOB_PYTHON": sys.executable,
})
if extra_env:
env.update(extra_env)
return subprocess.run(["bash", str(ONBOARD_SH)] + list(args), capture_output=True, text=True,
env=env, cwd=str(sandbox), timeout=120)
def _registered(sandbox: Path, uuid: str) -> bool:
return uuid in _run_onboard(["--list"], sandbox).stdout
# ---------------------------------------------------------------------------
# #10 — agy adapter
# ---------------------------------------------------------------------------
def test_agy_purge_removes_sidecars_annotation_and_lock(tmp_path, conv):
paths = _agy_tree(tmp_path, conv)
adapter = get_adapter("agy")
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="agy", home_dir=str(tmp_path))
purged = adapter.purge_artifacts(conv, ctx)
assert len(purged) == 6, purged
for key, p in paths.items():
assert not p.exists(), f"{key} survived the purge: {p}"
assert adapter.residual_artifacts(conv, ctx) == []
def test_agy_purge_keeps_presence_lock_while_in_use(tmp_path, conv):
paths = _agy_tree(tmp_path, conv)
adapter = get_adapter("agy")
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="agy", home_dir=str(tmp_path))
holder = _holder(conv)
try:
purged = adapter.purge_artifacts(conv, ctx)
finally:
_stop(holder)
assert paths["lock"].exists(), "presence lock must survive while a process references the conversation"
assert str(paths["lock"]) not in purged
for key in ("db", "wal", "shm", "brain", "annotation"):
assert not paths[key].exists(), key
assert adapter.residual_artifacts(conv, ctx) == [str(paths["lock"])]
def test_agy_purge_reports_only_what_existed(tmp_path, conv):
"""The historical 2-item contract (db + brain) still holds when no sidecar
exists (test_a4_adapter_contract pins len == 2)."""
root = tmp_path / ".gemini" / "antigravity-cli"
(root / "conversations").mkdir(parents=True)
(root / "conversations" / f"{conv}.db").write_text("db")
(root / "brain" / conv).mkdir(parents=True)
adapter = get_adapter("agy")
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="agy", home_dir=str(tmp_path))
assert len(adapter.purge_artifacts(conv, ctx)) == 2
def test_conversation_in_use_ignores_own_lineage(other):
"""The purge command names the uuid on its own command line (and its
parent shell does); only *other* processes count as holders."""
assert conversation_in_use(other) is False
holder = _holder(other)
try:
assert conversation_in_use(other) is True
finally:
_stop(holder)
assert conversation_in_use(other) is False
def test_conversation_in_use_excludes_only_the_non_agent_helper_chain(tmp_path, other):
"""The purge's own helper chain names the uuid too - the python argv and
the script that launched it - and must not count. The exclusion stops at
the nearest agent CLI ancestor, though: that process is the session the
purge runs in, and when it names the uuid the conversation is live."""
probe = ("import sys; sys.path.insert(0, %r); "
"from lib_py.agents.base import conversation_in_use; "
"print(conversation_in_use(sys.argv[1]))") % str(SKILLS)
helper = tmp_path / "helper.sh"
helper.write_text('#!/bin/sh\n"$@"\nexit $?\n')
helper.chmod(0o755)
alone = subprocess.run([str(helper), sys.executable, "-c", probe, other],
capture_output=True, text=True, env=_clean_env(), timeout=60)
assert alone.stdout.strip() == "False", alone.stdout + alone.stderr
cli = _fake_agent_cli(tmp_path, "agy")
under_agent = subprocess.run([str(cli), "--conversation", other, "--",
str(helper), sys.executable, "-c", probe, other],
capture_output=True, text=True, env=_clean_env(), timeout=60)
assert under_agent.stdout.strip() == "True", under_agent.stdout + under_agent.stderr
# ---------------------------------------------------------------------------
# #13 — lib_py.agents purge CLI
# ---------------------------------------------------------------------------
def _purge_cli(agent: str, uuid: str, home: Path, extra_env=None, prefix=()) -> subprocess.CompletedProcess:
env = _clean_env(extra_env)
env["HOME_DIR"] = str(home)
cmd = list(prefix) + [sys.executable, "-m", "lib_py.agents", "purge", agent, uuid, str(home)]
return subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=str(home), timeout=60)
def test_purge_cli_auto_resolves_agy_from_disk(tmp_path, conv):
paths = _agy_tree(tmp_path, conv)
res = _purge_cli("auto", conv, tmp_path)
assert res.returncode == 0, res.stderr
out = json.loads(res.stdout)
assert out["agent"] == "agy"
assert len(out["purged"]) == 6 and out["residual"] == [] and out["in_use"] is False
assert not any(p.exists() for p in paths.values())
def test_purge_cli_refuses_while_in_use(tmp_path, conv):
paths = _agy_tree(tmp_path, conv)
holder = _holder(conv)
try:
res = _purge_cli("agy", conv, tmp_path)
finally:
_stop(holder)
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert all(p.exists() for p in paths.values()), "a refused purge must remove nothing"
def test_purge_cli_refuses_inside_the_agent_session_that_owns_the_uuid(tmp_path, conv):
"""An orchestrator purging its own conversation from its own session: the
purge runs as a descendant of the agent CLI whose command line names the
uuid. That ancestor is the conversation's live process, not part of the
purge's helper chain, so the purge must be refused and delete nothing."""
paths = _agy_tree(tmp_path, conv)
cli = _fake_agent_cli(tmp_path, "claude")
res = _purge_cli("agy", conv, tmp_path, prefix=(str(cli), "--session-id", conv, "--"))
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert all(p.exists() for p in paths.values()), "a refused purge must remove nothing"
@pytest.mark.parametrize("var", SESSION_ENV)
def test_purge_cli_refuses_from_the_sessions_own_shell(tmp_path, var, conv):
"""The session a tool shell belongs to is in its environment even when no
process carries the uuid in argv and nothing holds the artifact open
(claude keeps no transcript open): a purge run from there is refused."""
paths = _agy_tree(tmp_path, conv)
res = _purge_cli("agy", conv, tmp_path, extra_env={var: conv})
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert all(p.exists() for p in paths.values()), "a refused purge must remove nothing"
def test_purge_cli_unresolved_uuid(tmp_path, other):
res = _purge_cli("auto", other, tmp_path)
assert res.returncode == 5, res.stdout + res.stderr
assert json.loads(res.stdout)["agent"] is None
def _lock_holder(lock: Path) -> subprocess.Popen:
"""A process that holds the presence lock open (flock) but names the
conversation nowhere - not on its command line (the path travels through
the environment) and not in a session variable."""
code = ("import fcntl, os, time; f = open(os.environ['MAM_TEST_LOCK'], 'a'); "
"fcntl.flock(f, fcntl.LOCK_EX); time.sleep(60)")
proc = subprocess.Popen([sys.executable, "-c", code], env=dict(os.environ, MAM_TEST_LOCK=str(lock)))
time.sleep(0.5)
return proc
def test_purge_cli_refuses_when_only_the_presence_lock_is_held_open(tmp_path, conv):
"""codex R1: the in-use check must also ask lsof about the presence lock
and the sidecars, not just the DB - a holder can keep the lock open with
the uuid on no command line and in no session variable."""
paths = _agy_tree(tmp_path, conv)
adapter = get_adapter("agy")
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="agy", home_dir=str(tmp_path))
holder = _lock_holder(paths["lock"])
try:
assert adapter.conversation_in_use(conv, ctx) is True
res = _purge_cli("agy", conv, tmp_path)
finally:
_stop(holder)
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert all(p.exists() for p in paths.values()), "a refused purge must remove nothing"
assert adapter.conversation_in_use(conv, ctx) is False
def test_purge_cli_resolves_agy_from_orphaned_leftovers(tmp_path, conv):
"""codex R2: after the DB is gone (an interrupted or pre-#10 purge) the
WAL, annotation and lock must still resolve to agy and be purged, instead
of 'no artifact' leaving them behind forever."""
root = tmp_path / ".gemini" / "antigravity-cli"
leftovers = [root / "conversations" / f"{conv}.db-wal",
root / "annotations" / f"{conv}.pbtxt",
root / "presence" / f"{conv}.lock"]
for p in leftovers:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("x")
res = _purge_cli("auto", conv, tmp_path)
assert res.returncode == 0, res.stdout + res.stderr
out = json.loads(res.stdout)
assert out["agent"] == "agy"
assert sorted(out["purged"]) == sorted(str(p) for p in leftovers)
assert out["residual"] == []
assert not any(p.exists() for p in leftovers)
# ---------------------------------------------------------------------------
# #13 — orc_onboard.sh --remove --purge-conversation
# ---------------------------------------------------------------------------
def test_orc_remove_purge_requires_yes(mam_sandbox, conv):
paths = _agy_tree(mam_sandbox, conv)
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation"], mam_sandbox)
assert res.returncode == 4, res.stdout + res.stderr
assert "Re-run with --yes" in res.stderr
assert all(p.exists() for p in paths.values())
assert _registered(mam_sandbox, conv), "registration must be untouched without --yes"
def test_orc_remove_purge_deletes_and_unregisters(mam_sandbox, conv):
paths = _agy_tree(mam_sandbox, conv)
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes"], mam_sandbox)
assert res.returncode == 0, res.stdout + res.stderr
assert "Successfully removed orchestrator UUID" in res.stdout
assert res.stdout.count("purged: ") == 6, res.stdout
assert "residual: none (agent: agy)" in res.stdout
assert not any(p.exists() for p in paths.values())
assert not _registered(mam_sandbox, conv)
def test_orc_remove_purge_refused_while_in_use(mam_sandbox, conv):
paths = _agy_tree(mam_sandbox, conv)
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
holder = _holder(conv)
try:
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes"], mam_sandbox)
finally:
_stop(holder)
assert res.returncode == 5, res.stdout + res.stderr
assert "still in use" in res.stderr
assert all(p.exists() for p in paths.values())
assert _registered(mam_sandbox, conv), "a refused purge must leave the registration in place"
def test_orc_remove_purge_refused_from_the_orchestrators_own_session(mam_sandbox, conv):
"""The case the skill documents: the orchestrator removes and purges its
own UUID from inside its own session (its tool shell carries the session
id). The purge must be refused (exit 5) and the registration kept."""
paths = _agy_tree(mam_sandbox, conv)
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes"], mam_sandbox,
extra_env={"CLAUDE_CODE_SESSION_ID": conv})
assert res.returncode == 5, res.stdout + res.stderr
assert "still in use" in res.stderr
assert all(p.exists() for p in paths.values())
assert _registered(mam_sandbox, conv), "a refused purge must leave the registration in place"
def test_orc_remove_purge_without_artifacts_still_unregisters(mam_sandbox, conv):
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes"], mam_sandbox)
assert res.returncode == 0, res.stdout + res.stderr
assert "nothing to purge" in res.stderr
assert not _registered(mam_sandbox, conv)
def test_orc_purge_flag_needs_remove(mam_sandbox, conv):
res = _run_onboard(["--uuid", conv, "--purge-conversation", "--yes"], mam_sandbox)
assert res.returncode == 2
assert not _registered(mam_sandbox, conv)
def test_orc_dry_run_purge_changes_nothing(mam_sandbox, conv):
paths = _agy_tree(mam_sandbox, conv)
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes", "--dry-run"], mam_sandbox)
assert res.returncode == 0
assert "purge its conversation artifacts" in res.stdout
assert all(p.exists() for p in paths.values())
assert _registered(mam_sandbox, conv)