Files
multi-agent-mux/.agents/skills/lib_py/agents/base.py
T
GodopuandClaude Opus 5 7610258950 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
2026-09-13 09:34:20 +09:00

209 lines
7.8 KiB
Python

# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext
import os, json, sqlite3, shutil, subprocess
from typing import Optional, Dict, Any, List
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).
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 <uuid>`, `agy --conversation <uuid>`, ...). 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)
if len(parts) < 2 or not (parts[0].isdigit() and parts[1].isdigit()):
continue
pid, ppid = int(parts[0]), int(parts[1])
parents[pid] = ppid
commands[pid] = parts[2] if len(parts) > 2 else ""
if uuid in commands[pid]:
holders.append(pid)
if holders:
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 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"):
try:
r = subprocess.run(["lsof", "-t"] + existing, capture_output=True, text=True, timeout=10)
if r.stdout.strip():
return True
except Exception:
pass
return False
class SpawnSpec:
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
self.agent_name = agent_name
self.session_name = session_name
self.command = command
self.env = env or {}
class DiscoveryContext:
def __init__(self, workspace: str, agent_name: str = "", home_dir: Optional[str] = None,
claude_dir: Optional[str] = None, epoch: int = 0,
row: Optional[Dict[str, Any]] = None, mode: str = "discover"):
self.workspace = workspace
self.agent_name = agent_name
self.home_dir = resolve_home(home_dir)
self.claude_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{self.home_dir}/.claude/projects")
self.epoch = epoch
self.row = row or {}
self.mode = mode
@property
def ws_key(self) -> str:
return workspace_key(self.workspace)
@property
def cwd(self) -> str:
return (self.row.get("pane", {}).get("cwd", "") if isinstance(self.row, dict) else "") or self.workspace
class BaseAgentAdapter:
@property
def name(self) -> str:
raise NotImplementedError
@property
def own_key(self) -> str:
raise NotImplementedError
@property
def ready_tokens(self) -> str:
raise NotImplementedError
@property
def exit_key(self) -> str:
raise NotImplementedError
@property
def delegate_agent_key(self) -> str:
raise NotImplementedError
@property
def identity_cache_fields(self) -> tuple:
"""agent_identities 캐시의 에이전트별 필드명.
B-10(Option A)로 캐시 읽기 경로가 제거되어 현재 생산 소비자는 0건이지만,
캐시 쓰기 경로가 도입되면 즉시 필요한 유일한 스키마 기술이므로 존치한다.
임의 삭제 금지 — 삭제 시 4개 어댑터에 필드명을 다시 흩뿌려야 한다."""
raise NotImplementedError
@property
def input_prompt(self) -> Optional[str]:
return None
@property
def input_placeholder(self) -> Optional[str]:
return None
@property
def input_rule_pattern(self) -> Optional[str]:
return None
@property
def modal_tokens(self) -> Optional[str]:
return None
@property
def strong_ready_tokens(self) -> str:
return self.ready_tokens
@property
def weak_ready_tokens(self) -> str:
return ''
def derive_session_name(self, slug: str, role: str = "creator") -> str:
r = role.lower()
return f"{slug}-{r}-{self.name}"
def matches_session_name(self, session_name: str) -> bool:
for r in ('creator', 'planner', 'reviewer'):
if session_name.endswith(f"-{r}-{self.name}"):
return True
return session_name.endswith(f"-{self.name}")
def verify_session(self, ws: str, uuid: str, row: Optional[Dict[str, Any]] = None, mode: str = "discover") -> bool:
return verify_session_uuid(ws, self.name, uuid, row=row, mode=mode)
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
raise NotImplementedError
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
raise NotImplementedError
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
raise NotImplementedError
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Paths still carrying `uuid` after a purge. Adapters that know their
on-disk tree override this; the default has nothing to scan."""
return []
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
"""True while a live process still references the conversation."""
return conversation_in_use(uuid, [self.artifact_path(uuid, ctx)])
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
raise NotImplementedError
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
raise NotImplementedError
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
raise NotImplementedError
def discover(self, ctx: DiscoveryContext) -> list:
raise NotImplementedError