fix(issue-13): refuse to purge the conversation the caller is running in

Review of 0282312 (planner-reviewer-claude-01, N1): conversation_in_use
excluded the purge's whole ancestor lineage, and that lineage includes the
agent CLI that launched the skill. An orchestrator purging its own UUID
from its own session was therefore never refused - the only process naming
the UUID was excluded, and claude keeps no transcript open for lsof - so
its live conversation was deleted, contrary to what orc-onboard SKILL.md
promises.

- base.conversation_in_use: in use when one of the caller's session-id
  variables (CLAUDE_CODE_SESSION_ID, ANTIGRAVITY_CONVERSATION_ID,
  HERMES_SESSION_ID, GROK_SESSION_ID, OPENCODE_SESSION_ID) equals the uuid;
  the command-line check now excludes only the purge's own helper chain and
  stops at the nearest agent CLI ancestor (basename claude/agy/hermes/grok/
  opencode/codex, read like detect_nearest_agent), which counts as a holder
  when it names the uuid; the lsof check is unchanged
- tests: the non-agent helper chain is excluded but an agent ancestor is
  counted; the purge CLI is refused under an agent CLI naming the uuid and
  with each of the five session variables set to it; orc_onboard --remove
  --purge-conversation --yes is refused from the orchestrator's own session
  (exit 5, registration kept). The eight new cases fail on 0282312
- job_subscriber --registry-dir now defaults to default_registry_dir() like
  the other entry points (review R-d)
- orc-onboard SKILL.md spells out how the calling session is recognised;
  VERSIONS.md v4.4.3 item 2 and the new-test count (24)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-13 09:34:20 +09:00
1 parent 02823128fa
commit 7610258950
5 files changed
+151 -25

No files matched your search

+99 -9
View File
@@ -9,7 +9,8 @@ purgeable through orc_onboard.sh.
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.
through it, and refuses while a live process still names the UUID -
including the orchestrator's own session running the command.
"""
from __future__ import annotations
@@ -34,6 +35,21 @@ from lib_py.agents.base import DiscoveryContext, conversation_in_use # noqa: E4
UUID = "7d0b6f12-3a4c-4e5f-8a9b-0c1d2e3f4a5b"
OTHER = "11111111-2222-4333-8444-555555555555"
# 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 _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"
@@ -80,12 +96,21 @@ def _stop(proc: subprocess.Popen) -> None:
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 = dict(os.environ)
for k in ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID", "HERMES_SESSION_ID", "MAM_ORCHESTRATOR_UUIDS"):
env.pop(k, None)
env = _clean_env()
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),
@@ -159,13 +184,37 @@ def test_conversation_in_use_ignores_own_lineage():
assert conversation_in_use(OTHER) is False
def test_conversation_in_use_excludes_only_the_non_agent_helper_chain(tmp_path):
"""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) -> 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 _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):
@@ -190,6 +239,31 @@ def test_purge_cli_refuses_while_in_use(tmp_path):
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):
"""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, UUID)
cli = _fake_agent_cli(tmp_path, "claude")
res = _purge_cli("agy", UUID, tmp_path, prefix=(str(cli), "--session-id", UUID, "--"))
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):
"""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, UUID)
res = _purge_cli("agy", UUID, tmp_path, extra_env={var: UUID})
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
@@ -240,6 +314,22 @@ def test_orc_remove_purge_refused_while_in_use(mam_sandbox):
assert _registered(mam_sandbox, UUID), "a refused purge must leave the registration in place"
def test_orc_remove_purge_refused_from_the_orchestrators_own_session(mam_sandbox):
"""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, UUID)
assert _run_onboard(["--uuid", UUID], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", UUID, "--purge-conversation", "--yes"], mam_sandbox,
extra_env={"CLAUDE_CODE_SESSION_ID": UUID})
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)