fix(agents): claude purge removes every path the session owns (Issue #31)

The claude purge removed only <uuid>.jsonl. It now removes everything the
session alone owns: projects/<key>/<uuid>/, file-history/<uuid>/,
session-env/<uuid>/, jobs/<uuid[:8]>/ when its state.json names the session,
a stale sessions/<pid>.json with that pid's key file (name only, never read)
and <tmp>/claude-<uid>/*/<uuid>/. 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/<pid>.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 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-15 15:16:06 +00:00
1 parent 26e2c43753
commit 9ccd3c1835
2 files changed
+169 -7

No files matched your search

+162 -7
View File
@@ -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-<uid>. 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/<pid>.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/<pid>.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/<first 8 chars>/ 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:
+7
View File
@@ -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