fix(issue-31): a claude purge removes every path the session owns, reports what is left, and waits for the process; the stop docs say a purge removes the row #59

Merged
Godopu merged 26 commits from fix/issue-31 into main 2026-10-17 16:25:21 +00:00
2 changed files with 25 additions and 26 deletions
Showing only changes of commit 81b6c2ea6b - Show all commits

No files matched your search

+11 -14
View File
@@ -1,6 +1,6 @@
import os, re, json, glob, math, stat, time, shutil, calendar, subprocess import os, re, json, glob, math, stat, time, shutil, calendar, subprocess
from typing import Optional, Any from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names, _plausible_id from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names
from lib_py.verify_session import workspace_key from lib_py.verify_session import workspace_key
@@ -306,20 +306,15 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
never listed.""" never listed."""
# Checked before any path is built: whole directories named by the id # Checked before any path is built: whole directories named by the id
# are removed, so "", "." or ".." would reach file-history/ itself, # are removed, so "", "." or ".." would reach file-history/ itself,
# and "memory" the auto memory. An id that is not a uuid keeps the # and "memory" the auto memory. Only a uuid names a session.
# pre-#31 reach: the `<id>.jsonl` file alone. uuid = _canonical(uuid)
if not _plausible_id(uuid): if not uuid:
return [] return []
canon = _canonical(uuid)
cfg = _config_dir(ctx) cfg = _config_dir(ctx)
found = [] found = []
for pdir in _projects_dirs(ctx): for pdir in _projects_dirs(ctx):
base = os.path.join(pdir, ctx.ws_key, canon or uuid) base = os.path.join(pdir, ctx.ws_key, uuid)
found += [(pdir, p) for p in ((base + ".jsonl", base) if canon else (base + ".jsonl",)) found += [(pdir, p) for p in (base + ".jsonl", base) if os.path.lexists(p)]
if os.path.lexists(p)]
if not canon:
return found
uuid = canon
found += [(cfg, p) for p in (os.path.join(cfg, "file-history", uuid), found += [(cfg, p) for p in (os.path.join(cfg, "file-history", uuid),
os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)] os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)]
job = self._owned_job(uuid, ctx) job = self._owned_job(uuid, ctx)
@@ -370,7 +365,9 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
# shows up in residual_artifacts instead. # shows up in residual_artifacts instead.
# Ownership evidence (a job's state.json, a session file) is removed # Ownership evidence (a job's state.json, a session file) is removed
# only after what it vouches for, so a retry can still find the rest. # only after what it vouches for, so a retry can still find the rest.
uuid = _canonical(uuid) or uuid uuid = _canonical(uuid)
if not uuid:
return []
purged = [] purged = []
job = self._owned_job(uuid, ctx) job = self._owned_job(uuid, ctx)
for path in self._owned_paths(uuid, ctx): for path in self._owned_paths(uuid, ctx):
@@ -390,9 +387,9 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
roots (two levels), plus the content-matched jobs dir and session roots (two levels), plus the content-matched jobs dir and session
files with their (masked) key files. projects is not walked deeper; files with their (masked) key files. projects is not walked deeper;
it can be very large.""" it can be very large."""
if not _canonical(uuid):
return sorted(p for _, p in self._candidates(uuid, ctx))
uuid = _canonical(uuid) uuid = _canonical(uuid)
if not uuid:
return []
cfg = _config_dir(ctx) cfg = _config_dir(ctx)
pdirs = _projects_dirs(ctx) pdirs = _projects_dirs(ctx)
found = set(scan_names(cfg, uuid, skip=pdirs)) found = set(scan_names(cfg, uuid, skip=pdirs))
+14 -12
View File
@@ -420,27 +420,29 @@ def test_claude_roots_may_be_links(home, tmp_path, monkeypatch, conv):
assert adapter.residual_artifacts(conv, ctx) == [] assert adapter.residual_artifacts(conv, ctx) == []
def test_a_non_uuid_id_takes_only_its_transcript_file(home, tmp_path): @pytest.mark.parametrize("name", ["uuid-c", "memory", "notes"])
"""The pre-#31 reach, kept for ids that are not uuids (test_a4 purges def test_a_non_uuid_id_reaches_nothing(home, tmp_path, name):
"uuid-c"): the `<id>.jsonl` file, never a directory of that name.""" """Only a uuid names a claude session: no path is built for any other id,
not even the `<id>.jsonl` the adapter took before #31."""
ws = tmp_path / "ws" ws = tmp_path / "ws"
ws.mkdir() ws.mkdir()
proj = home / ".claude" / "projects" / workspace_key(str(ws)) proj = home / ".claude" / "projects" / workspace_key(str(ws))
proj.mkdir() proj.mkdir()
jsonl = proj / "uuid-c.jsonl" jsonl = proj / f"{name}.jsonl"
jsonl.write_text("{}\n") jsonl.write_text(json.dumps({"sessionId": name, "cwd": os.path.realpath(str(ws))}) + "\n")
kept = [proj / "uuid-c" / "a", home / ".claude" / "file-history" / "uuid-c" / "b"] kept = [jsonl, proj / name / "a", home / ".claude" / "file-history" / name / "b"]
for p in kept: for p in kept[1:]:
p.parent.mkdir(parents=True) p.parent.mkdir(parents=True)
p.write_text("x") p.write_text("x")
adapter = get_adapter("claude") adapter = get_adapter("claude")
ctx = _ctx(ws, home) ctx = _ctx(ws, home)
assert adapter.has_artifacts("uuid-c", ctx) is True assert adapter.has_artifacts(name, ctx) is False
assert adapter.residual_artifacts("uuid-c", ctx) == [str(jsonl)] assert adapter.residual_artifacts(name, ctx) == []
assert adapter.purge_artifacts("uuid-c", ctx) == [str(jsonl)] assert adapter.purge_artifacts(name, ctx) == []
assert not jsonl.exists() and all(p.exists() for p in kept) res = _agents_cli(["purge", "claude", name, str(ws)], ws)
assert adapter.residual_artifacts("uuid-c", ctx) == [] assert json.loads(res.stdout)["purged"] == [], res.stdout + res.stderr
assert all(p.exists() for p in kept)
def test_purge_cli_resolves_claude_from_leftovers_only(home, tmp_path, conv): def test_purge_cli_resolves_claude_from_leftovers_only(home, tmp_path, conv):