Review of0282312(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 on0282312- 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
209 lines
7.8 KiB
Python
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
|