From 76102589505b1ddf9ff54d5b73319d3292b0184e Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 09:34:20 +0900 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe --- .agents/skills/lib_py/agents/base.py | 58 ++++++++-- .../lib_py/delegation/job_subscriber.py | 4 +- .../multi-agent-mux-orc-onboard/SKILL.md | 2 +- VERSIONS.md | 4 +- tests/test_issue10_13_purge.py | 108 ++++++++++++++++-- 5 files changed, 151 insertions(+), 25 deletions(-) diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py index b75f47f..681c496 100644 --- a/.agents/skills/lib_py/agents/base.py +++ b/.agents/skills/lib_py/agents/base.py @@ -6,21 +6,53 @@ from lib_py.paths import resolve_home from lib_py.verify_session import verify_session_uuid, workspace_key +# Session-id variables the agent CLIs export to their tool shells (the list +# orc_onboard.sh's auto-detection reads). +AGENT_SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID", + "HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID") +# Process basenames that are agent CLIs (orc_onboard.sh detect_nearest_agent). +AGENT_CLI_NAMES = ("claude", "agy", "hermes", "grok", "opencode", "codex") + + +def _is_agent_cli(command: str) -> bool: + """An agent CLI process: some token before the first option has an agent + basename (`claude …`, `node …/claude …`, `/bin/sh …/agy …`) - the same + reading orc_onboard.sh's detect_nearest_agent applies.""" + for tok in command.split(): + if tok.startswith("-"): + break + if os.path.basename(tok) in AGENT_CLI_NAMES: + return True + return False + + def conversation_in_use(uuid: str, paths=()) -> bool: """A live process still references the conversation (Issues #10, #13). - Either its id is on a command line other than this process's own lineage - (`claude -r `, `agy --conversation `, ... - the purge command - itself names the uuid, so its ancestors are excluded), or one of `paths` - is held open (lsof, where available). A purge must not touch a - conversation that is in use. + A purge must not touch a conversation that is in use, which is any of: + + - the caller runs inside that conversation's own agent session: one of its + session-id variables (AGENT_SESSION_ENV) equals the uuid. An orchestrator + purging itself from its own tool shell hits exactly this, and claude + keeps no transcript open, so neither check below would see it; + - a process outside this purge's own helper chain names the uuid on its + command line (`claude -r `, `agy --conversation `, ...). The + helper chain - this python and the shells/scripts that launched it - + names the uuid too and is excluded, but only up to the nearest agent CLI + ancestor: that ancestor is the session the purge runs in, and if its + command line carries the uuid it is the conversation's live process; + - one of `paths` is held open (lsof, where available). """ + for var in AGENT_SESSION_ENV: + if os.environ.get(var, "").strip() == uuid: + return True try: out = subprocess.run(["ps", "-axo", "pid=,ppid=,command="], capture_output=True, text=True, timeout=10).stdout except Exception: out = "" parents: Dict[int, int] = {} + commands: Dict[int, str] = {} holders: List[int] = [] for line in out.splitlines(): parts = line.split(None, 2) @@ -28,15 +60,19 @@ def conversation_in_use(uuid: str, paths=()) -> bool: continue pid, ppid = int(parts[0]), int(parts[1]) parents[pid] = ppid - if len(parts) > 2 and uuid in parts[2]: + commands[pid] = parts[2] if len(parts) > 2 else "" + if uuid in commands[pid]: holders.append(pid) if holders: - lineage = set() - p = os.getpid() - while p and p not in lineage: - lineage.add(p) + me = os.getpid() + helper_chain = set() + p = me + while p and p not in helper_chain: + if p != me and _is_agent_cli(commands.get(p, "")): + break + helper_chain.add(p) p = parents.get(p, 0) - if any(h not in lineage for h in holders): + if any(h not in helper_chain for h in holders): return True existing = [p for p in paths if p and os.path.exists(p)] if existing and shutil.which("lsof"): diff --git a/.agents/skills/lib_py/delegation/job_subscriber.py b/.agents/skills/lib_py/delegation/job_subscriber.py index c1d860d..d350c07 100755 --- a/.agents/skills/lib_py/delegation/job_subscriber.py +++ b/.agents/skills/lib_py/delegation/job_subscriber.py @@ -34,9 +34,9 @@ from typing import Any, Dict, List, Optional, Set, Tuple from lib_py.delegation import mqtt_common from lib_py.delegation import registry from lib_py.delegation.mqtt_common import ( - DEFAULT_REGISTRY_DIR, SCHEMA_VERSION, broker_config_from_job, + default_registry_dir, load_job, make_client, ) @@ -427,7 +427,7 @@ def main(argv=None) -> int: help="expected dispatch token for CAS verification") parser.add_argument("--attempt-id", type=int, default=None, help="minimum attempt id to accept events for") - parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR) + parser.add_argument("--registry-dir", default=default_registry_dir()) parser.add_argument("-v", "--verbose", action="store_true") args = parser.parse_args(argv) diff --git a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md index 318df74..d03ea5f 100644 --- a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md +++ b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md @@ -26,7 +26,7 @@ Registers an orchestrator session UUID into the `orchestrator_uuids` list of `.m ### Options - `--uuid `: Explicitly register the specified orchestrator session UUID. - `--remove `: Remove the specified UUID from the `orchestrator_uuids` list. -- `--purge-conversation`: With `--remove`, also delete the conversation's on-disk artifacts through its agent adapter (Issue #13). Irreversible — requires `--yes`; refused (exit `5`, registration untouched) while a live process still references the conversation, e.g. when run from inside that very session. +- `--purge-conversation`: With `--remove`, also delete the conversation's on-disk artifacts through its agent adapter (Issue #13). Irreversible — requires `--yes`; refused (exit `5`, registration untouched) while a live process still references the conversation — including the session the command itself runs in: its session-id variable (`CLAUDE_CODE_SESSION_ID`, `ANTIGRAVITY_CONVERSATION_ID`, `HERMES_SESSION_ID`, `GROK_SESSION_ID`, `OPENCODE_SESSION_ID`) equals the UUID, or the nearest agent CLI ancestor names it on its command line. An orchestrator therefore cannot purge its own conversation from inside that very session. - `--yes`: Skip the `--purge-conversation` confirmation (without it the script prints the danger notice and exits `4`). - `--agent `: Adapter to purge with (`claude|agy|hermes|grok|opencode|codex`). Default `auto`: each adapter is asked whether it owns an artifact for the UUID (`agy` → `~/.gemini/antigravity-cli/conversations/.db` etc.). Needed because `orchestrator_uuids` stores bare UUIDs, not the agent kind. - `--list`: Display all currently registered orchestrator UUIDs. diff --git a/VERSIONS.md b/VERSIONS.md index 78e1934..5b48da6 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -43,13 +43,13 @@ #### 🌟 주요 개선 사항 1. **agy purge 부속 파일 완결 (Issue #10, `lib_py/agents/adapters/agy.py`)**: `purge_artifacts` 가 대화 DB·`brain/` 에 더해 SQLite 부속 파일(`-wal`/`-shm`/`-journal` — DB 를 열 때마다 생기고 stop 경로의 ID 캡처도 이를 만들며, 체크포인트되지 않은 WAL 에는 대화 내용이 남을 수 있다), 제목 annotation(`annotations/.pbtxt`), presence lock(`presence/.lock`)까지 지운다. presence lock 은 `conversation_in_use` 가 거짓일 때만 지우고, 사용 중이면 남긴 채 경고한다. 새 `residual_artifacts()` 는 `~/.gemini/antigravity-cli` 아래에 그 uuid 를 이름에 가진 경로가 남아 있으면 보고한다. db+brain 만 있는 경우 2건을 돌려주는 어댑터 계약은 유지. -2. **오케스트레이터 대화 purge CLI (Issue #13, `python -m lib_py.agents purge [workspace]`)**: `orchestrator_uuids` 는 uuid 만 저장하므로 디스크에서 어댑터를 판별한다. `auto` 는 `agy → claude → hermes → grok → codex → opencode` 순으로(opencode 는 모든 uuid 에 대해 존재하는 공유 DB 라 마지막) 각 어댑터의 `verify_artifact`(revalidate 모드 — 오케스트레이터 대화는 워크스페이스 캐시에 없음)를 먼저, 그다음 uuid 가 들어간 artifact 경로의 존재를 확인한다. 살아 있는 프로세스가 그 uuid 를 참조하면 거부한다(`base.conversation_in_use`: 호출자 자신의 조상 계보를 뺀 프로세스 명령줄 검사 + `lsof` 로 artifact 점유 확인). 결과는 JSON 한 줄(`agent`·`purged`·`residual`·`in_use`)이며 종료 코드는 0 purge 완료 / 4 사용 중이라 거부(아무것도 삭제하지 않음) / 5 해당 uuid 의 artifact 없음. +2. **오케스트레이터 대화 purge CLI (Issue #13, `python -m lib_py.agents purge [workspace]`)**: `orchestrator_uuids` 는 uuid 만 저장하므로 디스크에서 어댑터를 판별한다. `auto` 는 `agy → claude → hermes → grok → codex → opencode` 순으로(opencode 는 모든 uuid 에 대해 존재하는 공유 DB 라 마지막) 각 어댑터의 `verify_artifact`(revalidate 모드 — 오케스트레이터 대화는 워크스페이스 캐시에 없음)를 먼저, 그다음 uuid 가 들어간 artifact 경로의 존재를 확인한다. 살아 있는 프로세스가 그 uuid 를 참조하면 거부한다(`base.conversation_in_use`: ① 호출 환경의 세션 id — `CLAUDE_CODE_SESSION_ID`·`ANTIGRAVITY_CONVERSATION_ID`·`HERMES_SESSION_ID`·`GROK_SESSION_ID`·`OPENCODE_SESSION_ID` — 가 그 uuid 이면 사용 중, 즉 오케스트레이터가 자기 세션 안에서 자기 대화를 purge 하는 경우를 막는다(claude 는 transcript 를 열어 두지 않아 아래 두 검사로는 보이지 않는다). ② 프로세스 명령줄 검사에서 purge 자신의 헬퍼 사슬(python·셸·스크립트)만 빼되 **가장 가까운 에이전트 CLI 조상에서 멈추고**, 그 조상의 명령줄에 uuid 가 있으면 보유자로 센다. ③ `lsof` 로 artifact 점유 확인). 결과는 JSON 한 줄(`agent`·`purged`·`residual`·`in_use`)이며 종료 코드는 0 purge 완료 / 4 사용 중이라 거부(아무것도 삭제하지 않음) / 5 해당 uuid 의 artifact 없음. 3. **`orc_onboard.sh --remove --purge-conversation --yes [--agent ]` (Issue #13)**: 위 purge 를 **레지스트리 갱신 전에** 실행하므로 거부되면 등록이 바뀌지 않는다. `--yes` 가 없으면 경고 후 exit 4, 대화가 사용 중이면 exit 5(등록 유지), 디스크에 대화가 없으면 경고 후 등록만 해제, `--dry-run` 은 예정 작업만 출력, `--purge-conversation` 은 `--remove` 와 함께일 때만 허용(exit 2). 성공 시 purge 된 경로와 잔여(residual) 검사 결과를 출력한다. 4. **`orc_onboard.sh` 성공 메시지 과거형 (Issue #11)**: `add`/`remove` 를 `added`/`removed` 로 명시 활용해 "Successfully removeed" 를 없앴다. 5. **잡 레지스트리·감사 로그의 워크스페이스 고정 (Issue #15, `mqtt_common.default_registry_dir`·`get_logs_dir`)**: 기본 경로가 `MAM_JOBS_DIR`/`DELEGATE_JOB_LOGS_DIR` → `$WORKSPACE_ROOT/.mam/jobs`·`$WORKSPACE_ROOT/.mam/delegate_job_logs` → cwd 기준 `.mam/…` 순으로 정해진다. `registry.py`·`publish_event.py`·`service.py` 와 `create_session.sh`·`stop_session.sh` 가 같은 순서를 쓴다. 저장소 루트에서 돈 pytest 스위트(기본 온보딩 잡을 등록하는 `create_session.sh`)가 호출자 cwd 를 따라 실제 `.mam/jobs` 에 테스트 레코드 593건을 남기던 문제를 해소했고, 샌드박스 픽스처(`tests/conftest.py`)는 두 환경변수를 모두 고정한다. #### ✅ 품질 검증 -- **신규 테스트 20개**: `tests/test_issue10_13_purge.py` 13개(agy 부속 파일·annotation·lock 삭제, 사용 중 lock 보존, 존재한 것만 보고, 자기 계보를 뺀 in-use 판정, purge CLI 의 auto 판별·사용 중 거부·미해결 uuid, `orc_onboard` purge 의 `--yes` 요구·삭제 후 등록 해제·사용 중 거부·artifact 없음에도 등록 해제·`--remove` 필수·dry-run 무변경), `tests/test_issue15_registry_isolation.py` 7개(`MAM_JOBS_DIR` 우선, `WORKSPACE_ROOT` 추종, cwd 폴백, 로그 경로 동일 순서, CLI 기본값의 프로세스별 해석, 명시 env 로 온보딩 잡 샌드박스 유지, `WORKSPACE_ROOT` 만으로도 샌드박스 유지). +- **신규 테스트 24개**: `tests/test_issue10_13_purge.py` 17개(agy 부속 파일·annotation·lock 삭제, 사용 중 lock 보존, 존재한 것만 보고, 자기 계보를 뺀 in-use 판정, 비-에이전트 헬퍼 사슬만 제외하고 에이전트 CLI 조상은 검사, purge CLI 의 auto 판별·사용 중 거부·자기 에이전트 세션 안에서 거부·세션 id 환경변수 5종으로 거부·미해결 uuid, `orc_onboard` purge 의 `--yes` 요구·삭제 후 등록 해제·사용 중 거부·오케스트레이터 자기 세션에서 거부·artifact 없음에도 등록 해제·`--remove` 필수·dry-run 무변경), `tests/test_issue15_registry_isolation.py` 7개(`MAM_JOBS_DIR` 우선, `WORKSPACE_ROOT` 추종, cwd 폴백, 로그 경로 동일 순서, CLI 기본값의 프로세스별 해석, 명시 env 로 온보딩 잡 샌드박스 유지, `WORKSPACE_ROOT` 만으로도 샌드박스 유지). - **기존 테스트 조정**: O-13(`tests/test_orc_onboard.py`)이 정정된 메시지를 단정하고, R1 헬퍼·B-9 로그 경로 테스트와 `publish_event` 스냅샷(`tests/test_issue5_phase1_delegation.py`·`tests/test_tier1_unit.py`)이 워크스페이스 고정 계약에 맞게 조정됨. - **실측 (커밋 `c82ea75`·위임 브리프 기록)**: 전체 스위트 839/839 통과, 스위트 실행 전후 실제 `.mam/jobs` 레코드 881건 불변. diff --git a/tests/test_issue10_13_purge.py b/tests/test_issue10_13_purge.py index a76443c..568958a 100644 --- a/tests/test_issue10_13_purge.py +++ b/tests/test_issue10_13_purge.py @@ -9,7 +9,8 @@ purgeable through orc_onboard.sh. conversation any more. #13: `orc_onboard.sh --remove --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. ` -- 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)