fix(issue-10,11,13,15): complete agy purge, orchestrator purge, workspace-anchored registry

Issue #10 - agy purge_artifacts left resumable pieces behind: it now also
removes the SQLite sidecars (-wal/-shm/-journal), the title annotation and
the presence lock (the lock only once no live process references the
conversation), and residual_artifacts() reports anything under
~/.gemini/antigravity-cli still carrying the uuid.

Issue #13 - the orchestrator has no herdr_sessions row, so its conversation
could not be purged by any skill. `lib_py.agents purge <agent|auto> <uuid>`
resolves the adapter from what is on disk (orchestrator_uuids stores bare
uuids), refuses while a live process names the uuid (conversation_in_use:
process list minus the caller's own lineage, plus lsof), and prints what it
purged. `orc_onboard.sh --remove <uuid> --purge-conversation --yes [--agent]`
runs it before the registry update so a refusal changes nothing (exit 4
without --yes, 5 while in use).

Issue #11 - "Successfully removeed": conjugate add/remove explicitly.

Issue #15 - the job registry and audit log followed the caller's cwd, so the
pytest suite (create_session.sh with its default onboarding job, run from the
repo root) had left 593 test records in the real .mam/jobs. Defaults now
resolve MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR, then $WORKSPACE_ROOT/.mam/...,
then the cwd (mqtt_common.default_registry_dir / get_logs_dir; registry.py,
publish_event.py, service.py, create_session.sh, stop_session.sh); the
sandbox fixture pins both. The tier suites that used to leak now leave the
real registry untouched (881 records before and after).

Tests: test_issue10_13_purge.py (13), test_issue15_registry_isolation.py (7);
T-O13 pins the corrected message; the R1 helper test and B-9 logs-dir test
now assert the workspace-anchored contract; publish_event snapshot updated.

Refs #10 #11 #13 #15

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-13 08:18:56 +09:00
1 parent bd3df890dc
commit c82ea75a15
20 files changed
+723 -33

No files matched your search

+264
View File
@@ -0,0 +1,264 @@
#!/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.
"""
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import sys
import time
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
UUID = "7d0b6f12-3a4c-4e5f-8a9b-0c1d2e3f4a5b"
OTHER = "11111111-2222-4333-8444-555555555555"
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 _run_onboard(args, sandbox: Path, extra_env=None) -> subprocess.CompletedProcess:
env = dict(os.environ)
for k in ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID", "HERMES_SESSION_ID", "MAM_ORCHESTRATOR_UUIDS"):
env.pop(k, None)
env.update({
"PYTHONPATH": f"{SKILLS}:{env.get('PYTHONPATH', '')}",
"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):
paths = _agy_tree(tmp_path, UUID)
adapter = get_adapter("agy")
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="agy", home_dir=str(tmp_path))
purged = adapter.purge_artifacts(UUID, 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(UUID, ctx) == []
def test_agy_purge_keeps_presence_lock_while_in_use(tmp_path):
paths = _agy_tree(tmp_path, UUID)
adapter = get_adapter("agy")
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="agy", home_dir=str(tmp_path))
holder = _holder(UUID)
try:
purged = adapter.purge_artifacts(UUID, 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(UUID, ctx) == [str(paths["lock"])]
def test_agy_purge_reports_only_what_existed(tmp_path):
"""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"{UUID}.db").write_text("db")
(root / "brain" / UUID).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(UUID, ctx)) == 2
def test_conversation_in_use_ignores_own_lineage():
"""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
# ---------------------------------------------------------------------------
# #13 — lib_py.agents purge CLI
# ---------------------------------------------------------------------------
def _purge_cli(agent: str, uuid: str, home: Path) -> subprocess.CompletedProcess:
env = dict(os.environ, HOME_DIR=str(home), PYTHONPATH=f"{SKILLS}:{os.environ.get('PYTHONPATH', '')}")
return subprocess.run([sys.executable, "-m", "lib_py.agents", "purge", agent, uuid, str(home)],
capture_output=True, text=True, env=env, cwd=str(home), timeout=60)
def test_purge_cli_auto_resolves_agy_from_disk(tmp_path):
paths = _agy_tree(tmp_path, UUID)
res = _purge_cli("auto", UUID, 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):
paths = _agy_tree(tmp_path, UUID)
holder = _holder(UUID)
try:
res = _purge_cli("agy", UUID, 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_unresolved_uuid(tmp_path):
res = _purge_cli("auto", OTHER, tmp_path)
assert res.returncode == 5, res.stdout + res.stderr
assert json.loads(res.stdout)["agent"] is None
# ---------------------------------------------------------------------------
# #13 — orc_onboard.sh --remove --purge-conversation
# ---------------------------------------------------------------------------
def test_orc_remove_purge_requires_yes(mam_sandbox):
paths = _agy_tree(mam_sandbox, UUID)
assert _run_onboard(["--uuid", UUID], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", UUID, "--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, UUID), "registration must be untouched without --yes"
def test_orc_remove_purge_deletes_and_unregisters(mam_sandbox):
paths = _agy_tree(mam_sandbox, UUID)
assert _run_onboard(["--uuid", UUID], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", UUID, "--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, UUID)
def test_orc_remove_purge_refused_while_in_use(mam_sandbox):
paths = _agy_tree(mam_sandbox, UUID)
assert _run_onboard(["--uuid", UUID], mam_sandbox).returncode == 0
holder = _holder(UUID)
try:
res = _run_onboard(["--remove", UUID, "--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, UUID), "a refused purge must leave the registration in place"
def test_orc_remove_purge_without_artifacts_still_unregisters(mam_sandbox):
assert _run_onboard(["--uuid", UUID], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", UUID, "--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, UUID)
def test_orc_purge_flag_needs_remove(mam_sandbox):
res = _run_onboard(["--uuid", UUID, "--purge-conversation", "--yes"], mam_sandbox)
assert res.returncode == 2
assert not _registered(mam_sandbox, UUID)
def test_orc_dry_run_purge_changes_nothing(mam_sandbox):
paths = _agy_tree(mam_sandbox, UUID)
assert _run_onboard(["--uuid", UUID], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", UUID, "--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, UUID)