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)