fix(agents): only a canonical uuid reaches any claude path (Issue #31, review)

The user asked for no path at all from an id that is not a uuid. bd09c6a
still took `<id>.jsonl` for one (the pre-#31 reach), which has_artifacts and
the residual counted while in-use always said free: `purge claude notes`
deleted notes.jsonl. Now purge, has_artifacts and residual return nothing
for such an id, as in-use already did.

This makes test_a4_adapter_contract.py::test_purge_artifacts_composite fail:
it purges the claude id 'uuid-c' and expects uuid-c.jsonl gone. Editing an
existing test needs the user's approval; the one-line data change
(a canonical uuid in place of 'uuid-c') is kept outside the branch, in job
90efa4ce's report dir. Reverting this commit restores the jsonl-only reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-16 04:09:32 +00:00
1 parent dd292c12cb
commit 81b6c2ea6b
2 files changed
+25 -26

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):