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
No files matched your search
@@ -4,6 +4,16 @@ from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_
|
|||||||
from lib_py.verify_session import workspace_key
|
from lib_py.verify_session import workspace_key
|
||||||
|
|
||||||
|
|
||||||
|
_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_uuid(uuid) -> bool:
|
||||||
|
"""claude names every session by a canonical uuid. Any other name in a
|
||||||
|
session's place is not one: projects/<key>/memory/ (auto memory) sits
|
||||||
|
beside the session dirs."""
|
||||||
|
return isinstance(uuid, str) and bool(_UUID.fullmatch(uuid))
|
||||||
|
|
||||||
|
|
||||||
def _config_dir(ctx: DiscoveryContext) -> str:
|
def _config_dir(ctx: DiscoveryContext) -> str:
|
||||||
"""claude's config root: CLAUDE_CONFIG_DIR moves all of it (sessions/,
|
"""claude's config root: CLAUDE_CONFIG_DIR moves all of it (sessions/,
|
||||||
file-history/, session-env/, jobs/, projects/)."""
|
file-history/, session-env/, jobs/, projects/)."""
|
||||||
@@ -262,14 +272,19 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
|||||||
Shared files that merely mention it (history.jsonl, ~/.claude.json,
|
Shared files that merely mention it (history.jsonl, ~/.claude.json,
|
||||||
backups, paste-cache) are not the session's and are never listed."""
|
backups, paste-cache) are not the session's and are 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
|
||||||
|
# pre-#31 reach: the `<id>.jsonl` file alone.
|
||||||
if not _plausible_id(uuid):
|
if not _plausible_id(uuid):
|
||||||
return []
|
return []
|
||||||
cfg = _config_dir(ctx)
|
cfg = _config_dir(ctx)
|
||||||
paths = []
|
paths = []
|
||||||
for pdir in _projects_dirs(ctx):
|
for pdir in _projects_dirs(ctx):
|
||||||
base = os.path.join(pdir, ctx.ws_key, uuid)
|
base = os.path.join(pdir, ctx.ws_key, uuid)
|
||||||
paths += [p for p in (base + ".jsonl", base) if os.path.lexists(p)]
|
paths += [p for p in ((base + ".jsonl", base) if _is_uuid(uuid) else (base + ".jsonl",))
|
||||||
|
if os.path.lexists(p)]
|
||||||
|
if not _is_uuid(uuid):
|
||||||
|
return paths
|
||||||
paths += [p for p in (os.path.join(cfg, "file-history", uuid),
|
paths += [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)
|
||||||
@@ -293,6 +308,10 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
|||||||
return bool(self._owned_paths(uuid, ctx))
|
return bool(self._owned_paths(uuid, ctx))
|
||||||
|
|
||||||
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||||
|
# No claude process holds a name that is not a uuid; searching
|
||||||
|
# command lines for one ("a/b", "memory") only finds strangers.
|
||||||
|
if not _is_uuid(uuid):
|
||||||
|
return False
|
||||||
if conversation_in_use(uuid, [self.artifact_path(uuid, ctx)]):
|
if conversation_in_use(uuid, [self.artifact_path(uuid, ctx)]):
|
||||||
return True
|
return True
|
||||||
return any(_session_file_live(data, pid) for _, pid, data in self._session_files(uuid, ctx))
|
return any(_session_file_live(data, pid) for _, pid, data in self._session_files(uuid, ctx))
|
||||||
@@ -322,8 +341,8 @@ 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 _plausible_id(uuid):
|
if not _is_uuid(uuid):
|
||||||
return []
|
return self._owned_paths(uuid, ctx)
|
||||||
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))
|
||||||
|
|||||||
@@ -262,18 +262,55 @@ def test_claude_residual_lists_leftovers_then_nothing(home, tmp_path, conv, othe
|
|||||||
assert adapter.residual_artifacts(conv, ctx) == [str(stray)]
|
assert adapter.residual_artifacts(conv, ctx) == [str(stray)]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad", ["", ".", "..", "*", "../x", "a/b"])
|
@pytest.mark.parametrize("bad", ["", ".", "..", "*", "../x", "a/b", "memory", " ", "notes",
|
||||||
|
"12345678-1234-1234-1234-12345678901g", "{conv} ", "x{conv}"])
|
||||||
def test_claude_rejects_malformed_uuid(home, tmp_path, conv, bad):
|
def test_claude_rejects_malformed_uuid(home, tmp_path, conv, bad):
|
||||||
|
bad = bad.format(conv=conv)
|
||||||
ws = tmp_path / "ws"
|
ws = tmp_path / "ws"
|
||||||
ws.mkdir()
|
ws.mkdir()
|
||||||
t = _tree(home, ws, conv, tmp_path / "ctmp")
|
t = _tree(home, ws, conv, tmp_path / "ctmp")
|
||||||
|
# Claude Code's auto memory sits beside the session dirs, under the one
|
||||||
|
# non-uuid name there.
|
||||||
|
memory = home / ".claude" / "projects" / workspace_key(str(ws)) / "memory" / "MEMORY.md"
|
||||||
|
memory.parent.mkdir()
|
||||||
|
memory.write_text("m")
|
||||||
|
named = [home / ".claude" / d / bad / "x" for d in ("file-history", "session-env")
|
||||||
|
if bad.strip(". ") and "/" not in bad and "*" not in bad]
|
||||||
|
for p in named:
|
||||||
|
p.parent.mkdir(parents=True)
|
||||||
|
p.write_text("x")
|
||||||
adapter = get_adapter("claude")
|
adapter = get_adapter("claude")
|
||||||
ctx = _ctx(ws, home)
|
ctx = _ctx(ws, home)
|
||||||
|
|
||||||
assert adapter.purge_artifacts(bad, ctx) == []
|
assert adapter.purge_artifacts(bad, ctx) == []
|
||||||
assert adapter.residual_artifacts(bad, ctx) == []
|
assert adapter.residual_artifacts(bad, ctx) == []
|
||||||
assert adapter.has_artifacts(bad, ctx) is False
|
assert adapter.has_artifacts(bad, ctx) is False
|
||||||
|
assert adapter.conversation_in_use(bad, ctx) is False
|
||||||
assert all(p.exists() for p in t.values())
|
assert all(p.exists() for p in t.values())
|
||||||
|
assert memory.exists() and all(p.exists() for p in named)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_uuid_id_takes_only_its_transcript_file(home, tmp_path):
|
||||||
|
"""The pre-#31 reach, kept for ids that are not uuids (test_a4 purges
|
||||||
|
"uuid-c"): the `<id>.jsonl` file, never a directory of that name."""
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
ws.mkdir()
|
||||||
|
proj = home / ".claude" / "projects" / workspace_key(str(ws))
|
||||||
|
proj.mkdir()
|
||||||
|
jsonl = proj / "uuid-c.jsonl"
|
||||||
|
jsonl.write_text("{}\n")
|
||||||
|
kept = [proj / "uuid-c" / "a", home / ".claude" / "file-history" / "uuid-c" / "b"]
|
||||||
|
for p in kept:
|
||||||
|
p.parent.mkdir(parents=True)
|
||||||
|
p.write_text("x")
|
||||||
|
adapter = get_adapter("claude")
|
||||||
|
ctx = _ctx(ws, home)
|
||||||
|
|
||||||
|
assert adapter.has_artifacts("uuid-c", ctx) is True
|
||||||
|
assert adapter.residual_artifacts("uuid-c", ctx) == [str(jsonl)]
|
||||||
|
assert adapter.purge_artifacts("uuid-c", ctx) == [str(jsonl)]
|
||||||
|
assert not jsonl.exists() and all(p.exists() for p in kept)
|
||||||
|
assert adapter.residual_artifacts("uuid-c", ctx) == []
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
|||||||
Reference in new issue
Block a user