From 9ccd3c183518aca79a37086db5a5b7b745acb3f9 Mon Sep 17 00:00:00 2001 From: Godopu Date: Thu, 15 Oct 2026 15:16:06 +0000 Subject: [PATCH] fix(agents): claude purge removes every path the session owns (Issue #31) The claude purge removed only .jsonl. It now removes everything the session alone owns: projects///, file-history//, session-env//, jobs// when its state.json names the session, a stale sessions/.json with that pid's key file (name only, never read) and /claude-/*//. An id that is empty, all dots or holds /, .. or * builds no path. has_artifacts finds leftovers without the transcript, and conversation_in_use also sees a live claude through sessions/.json (a `claude -c` names no uuid on its command line); a reused pid is told apart by ps lstart against the file's startedAt. Co-Authored-By: Claude Opus 5.5 --- .../skills/lib_py/agents/adapters/claude.py | 169 +++++++++++++++++- .agents/skills/lib_py/agents/base.py | 7 + 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/.agents/skills/lib_py/agents/adapters/claude.py b/.agents/skills/lib_py/agents/adapters/claude.py index bf60fc7..a8bef1d 100644 --- a/.agents/skills/lib_py/agents/adapters/claude.py +++ b/.agents/skills/lib_py/agents/adapters/claude.py @@ -1,8 +1,84 @@ -import os, json, glob, subprocess +import os, re, json, glob, time, shutil, subprocess from typing import Optional, Any -from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext +from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, _plausible_id from lib_py.verify_session import workspace_key + +def _config_dir(ctx: DiscoveryContext) -> str: + return os.path.join(ctx.home_dir, ".claude") + + +def _tmp_roots() -> list: + """The per-uid temp roots claude may have used: ${CLAUDE_CODE_TMPDIR:- + os.tmpdir()}/claude-. A sandboxed tool shell sets CLAUDE_CODE_TMPDIR + to that per-uid root itself. The session that wrote the files may have + run under another env, so every candidate that exists is returned.""" + name = f"claude-{os.getuid()}" + cands = [] + override = os.environ.get("CLAUDE_CODE_TMPDIR", "") + if override: + cands.append(os.path.join(override, name)) + if os.path.basename(override.rstrip("/")) == name: + cands.append(override) + # Node's os.tmpdir() on POSIX, not tempfile.gettempdir() (which probes + # writability and falls back to the cwd). + tmp = os.environ.get("TMPDIR") or os.environ.get("TMP") or os.environ.get("TEMP") or "/tmp" + cands.append(os.path.join(tmp.rstrip("/") or "/", name)) + cands.append(os.path.join("/tmp", name)) + roots, seen = [], set() + for c in cands: + real = os.path.realpath(c) + if os.path.isdir(c) and real not in seen: + seen.add(real) + roots.append(c) + return roots + + +def _session_file_live(data: dict, pid) -> bool: + """The process that wrote this sessions/.json is still running. + + A dead pid is stale. A live pid whose start (`ps -o lstart=`) is later + than the file's startedAt was reused by another process, so it is stale + too. Anything that cannot be told is taken as live: a purge is then + refused, never run over a live conversation.""" + if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0: + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except OSError: + pass + try: + out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True, + timeout=5, env=dict(os.environ, LC_ALL="C")).stdout + lstart = time.mktime(time.strptime(" ".join(out.split()), "%a %b %d %H:%M:%S %Y")) + started = float(data["startedAt"]) / 1000.0 + except Exception: + return True + # startedAt is written 1.7-3.2 s after the process starts; 2 s covers + # lstart's one-second resolution. + return lstart <= started + 2 + + +def _remove(path: str) -> None: + try: + if os.path.islink(path) or not os.path.isdir(path): + os.remove(path) + else: + shutil.rmtree(path, ignore_errors=True) + except OSError: + pass + + +def _label(path: str) -> str: + """How a path is printed: a session key file is named by pid only, its + hex part is not shown.""" + d, name = os.path.split(path) + m = re.fullmatch(r"(\d+)\.[0-9a-f]{64}\.key", name) + return os.path.join(d, f"{m.group(1)}.*.key") if m else path + + class ClaudeAgentAdapter(BaseAgentAdapter): @property def name(self) -> str: @@ -94,12 +170,91 @@ class ClaudeAgentAdapter(BaseAgentAdapter): return False return True - def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list: + def _session_files(self, uuid: str, ctx: DiscoveryContext) -> list: + """(path, pid, data) of each sessions/.json naming this session - + the registry of running claude processes, which a live `claude -c` + is found by even though its command line carries no uuid.""" + found = [] + for path in sorted(glob.glob(os.path.join(_config_dir(ctx), "sessions", "*.json"))): + try: + with open(path) as f: + data = json.load(f) + except Exception: + continue + if not isinstance(data, dict) or data.get("sessionId") != uuid: + continue + pid = data.get("pid") + if not isinstance(pid, int): + stem = os.path.basename(path)[:-5] + pid = int(stem) if stem.isdigit() else None + found.append((path, pid, data)) + return found + + def _owned_job(self, uuid: str, ctx: DiscoveryContext) -> Optional[str]: + """jobs// when its state.json names this session; the + short name alone can be another session's.""" + job = os.path.join(_config_dir(ctx), "jobs", uuid[:8]) + try: + with open(os.path.join(job, "state.json")) as f: + if json.load(f).get("sessionId") == uuid: + return job + except Exception: + pass + return None + + def _owned_paths(self, uuid: str, ctx: DiscoveryContext) -> list: + """Every existing path that belongs to this session alone (Issue #31). + Shared files that merely mention it (history.jsonl, ~/.claude.json, + backups, paste-cache) are not the session's and are never listed.""" + # Checked before any path is built: whole directories named by the id + # are removed, so "", "." or ".." would reach file-history/ itself. + if not _plausible_id(uuid): + return [] + cfg = _config_dir(ctx) jsonl = self.artifact_path(uuid, ctx) - if os.path.exists(jsonl): - os.remove(jsonl) - return [jsonl] - return [] + paths = [p for p in (jsonl, jsonl[:-len(".jsonl")], + os.path.join(cfg, "file-history", uuid), + os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)] + job = self._owned_job(uuid, ctx) + if job: + paths.append(job) + for path, pid, data in self._session_files(uuid, ctx): + if _session_file_live(data, pid): + continue + paths.append(path) + stem = os.path.basename(path)[:-len(".json")] + sessions = os.path.dirname(path) + if stem.isdigit(): + # The key file is matched by name only; its content is never read. + paths.extend(os.path.join(sessions, n) for n in sorted(os.listdir(sessions)) + if re.fullmatch(rf"{stem}\.[0-9a-f]{{64}}\.key", n)) + for root in _tmp_roots(): + try: + keys = sorted(os.listdir(root)) + except OSError: + continue + paths.extend(p for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p)) + return paths + + def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool: + # Leftovers count without the transcript (a pre-#31 purge left them). + return bool(self._owned_paths(uuid, ctx)) + + def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool: + if conversation_in_use(uuid, [self.artifact_path(uuid, ctx)]): + return True + return any(_session_file_live(data, pid) for _, pid, data in self._session_files(uuid, ctx)) + + def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list: + # Best effort: never raises (stop runs this inside its registry + # transaction). What could not be removed is not listed here and + # shows up in residual_artifacts instead. + purged = [] + for path in self._owned_paths(uuid, ctx): + _remove(path) + if not os.path.lexists(path) and _label(path) not in purged: + purged.append(_label(path)) + return purged def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: if use_wrapper or not session_uuid: diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py index 0fd62a6..7b5e977 100644 --- a/.agents/skills/lib_py/agents/base.py +++ b/.agents/skills/lib_py/agents/base.py @@ -84,6 +84,13 @@ def conversation_in_use(uuid: str, paths=()) -> bool: pass return False + +def _plausible_id(uuid: str) -> bool: + """An id safe to build a path from and to search for: not empty, not + made of dots alone ("." names the parent dir itself), no "/", ".." or "*".""" + return isinstance(uuid, str) and bool(uuid.strip(".")) and not any(s in uuid for s in ("/", "..", "*")) + + class SpawnSpec: def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None): self.agent_name = agent_name