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:
1 parent
26e2c43753
commit
9ccd3c1835
2 files changed
+169
-7
No files matched your search
@@ -1,8 +1,84 @@
|
|||||||
import os, json, glob, subprocess
|
import os, re, json, glob, time, shutil, subprocess
|
||||||
from typing import Optional, Any
|
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
|
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):
|
class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -94,12 +170,91 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
def _session_files(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||||
jsonl = self.artifact_path(uuid, ctx)
|
"""(path, pid, data) of each sessions/<pid>.json naming this session -
|
||||||
if os.path.exists(jsonl):
|
the registry of running claude processes, which a live `claude -c`
|
||||||
os.remove(jsonl)
|
is found by even though its command line carries no uuid."""
|
||||||
return [jsonl]
|
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 []
|
return []
|
||||||
|
cfg = _config_dir(ctx)
|
||||||
|
jsonl = self.artifact_path(uuid, ctx)
|
||||||
|
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:
|
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||||
if use_wrapper or not session_uuid:
|
if use_wrapper or not session_uuid:
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ def conversation_in_use(uuid: str, paths=()) -> bool:
|
|||||||
pass
|
pass
|
||||||
return False
|
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:
|
class SpawnSpec:
|
||||||
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
|
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
|
||||||
self.agent_name = agent_name
|
self.agent_name = agent_name
|
||||||
|
|||||||
Reference in new issue
Block a user