From c0248b88bed38f83c5f5a66cec51925ccf039640 Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 16 Oct 2026 04:06:37 +0000 Subject: [PATCH] fix(agents): any spelling of a claude uuid is checked as claude's lowercase id (Issue #31, review) _is_uuid took an uppercase or mixed-case uuid, and the paths built from it are the live lowercase session's own files on macOS's default case-insensitive APFS. The in-use checks compare exactly, though: session files and command lines carry the lowercase id, so `purge claude ` found no holder and could remove a live session's files. _canonical now lowercases a canonical uuid, and the paths, the session-file and job matches, the in-use check and the residual all use it; a command line is searched for both spellings. Co-Authored-By: Claude Opus 5.5 --- .../skills/lib_py/agents/adapters/claude.py | 36 +++++++++++------- tests/test_issue31_purge_residual.py | 37 +++++++++++++++++++ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/.agents/skills/lib_py/agents/adapters/claude.py b/.agents/skills/lib_py/agents/adapters/claude.py index bf74770..480ed52 100644 --- a/.agents/skills/lib_py/agents/adapters/claude.py +++ b/.agents/skills/lib_py/agents/adapters/claude.py @@ -7,11 +7,15 @@ 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//memory/ (auto memory) sits - beside the session dirs.""" - return isinstance(uuid, str) and bool(_UUID.fullmatch(uuid)) +def _canonical(uuid) -> Optional[str]: + """The id claude itself writes, in lowercase, for any spelling of a + canonical uuid; None for anything else. Any other name in a session's + place is not one: projects//memory/ (auto memory) sits beside the + session dirs. Another spelling must go through the same checks as + claude's own: on macOS's default case-insensitive APFS it names the + same files, while the ids in session files and on command lines are + compared exactly.""" + return uuid.lower() if isinstance(uuid, str) and _UUID.fullmatch(uuid) else None def _config_dir(ctx: DiscoveryContext) -> str: @@ -306,14 +310,16 @@ class ClaudeAgentAdapter(BaseAgentAdapter): # pre-#31 reach: the `.jsonl` file alone. if not _plausible_id(uuid): return [] + canon = _canonical(uuid) cfg = _config_dir(ctx) found = [] for pdir in _projects_dirs(ctx): - base = os.path.join(pdir, ctx.ws_key, uuid) - found += [(pdir, p) for p in ((base + ".jsonl", base) if _is_uuid(uuid) else (base + ".jsonl",)) + base = os.path.join(pdir, ctx.ws_key, canon or uuid) + found += [(pdir, p) for p in ((base + ".jsonl", base) if canon else (base + ".jsonl",)) if os.path.lexists(p)] - if not _is_uuid(uuid): + if not canon: return found + uuid = canon 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)] job = self._owned_job(uuid, ctx) @@ -344,15 +350,17 @@ class ClaudeAgentAdapter(BaseAgentAdapter): 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): + canon = _canonical(uuid) + if not canon: return False - if conversation_in_use(uuid, [self.artifact_path(uuid, ctx)]): + # claude's own spelling and the one asked about, on a command line. + if any(conversation_in_use(u, [self.artifact_path(canon, ctx)]) for u in {canon, uuid}): 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(canon, ctx)) def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list: return [f"a claude process whose session file under {os.path.join(_config_dir(ctx), 'sessions')}/ names it"] \ - + super().in_use_hints(uuid, ctx) + + super().in_use_hints(_canonical(uuid) or uuid, ctx) def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list: # Best effort: never raises (stop runs this inside its registry @@ -360,6 +368,7 @@ class ClaudeAgentAdapter(BaseAgentAdapter): # shows up in residual_artifacts instead. # 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. + uuid = _canonical(uuid) or uuid purged = [] job = self._owned_job(uuid, ctx) for path in self._owned_paths(uuid, ctx): @@ -379,8 +388,9 @@ class ClaudeAgentAdapter(BaseAgentAdapter): roots (two levels), plus the content-matched jobs dir and session files with their (masked) key files. projects is not walked deeper; it can be very large.""" - if not _is_uuid(uuid): + if not _canonical(uuid): return sorted(p for _, p in self._candidates(uuid, ctx)) + uuid = _canonical(uuid) cfg = _config_dir(ctx) pdirs = _projects_dirs(ctx) found = set(scan_names(cfg, uuid, skip=pdirs)) diff --git a/tests/test_issue31_purge_residual.py b/tests/test_issue31_purge_residual.py index da964b0..5d7bca5 100644 --- a/tests/test_issue31_purge_residual.py +++ b/tests/test_issue31_purge_residual.py @@ -290,6 +290,43 @@ def test_claude_rejects_malformed_uuid(home, tmp_path, conv, bad): assert memory.exists() and all(p.exists() for p in named) +@pytest.mark.parametrize("spell", [str.upper, lambda u: u[:18].upper() + u[18:]], ids=["upper", "mixed"]) +def test_any_spelling_of_the_uuid_is_the_lowercase_session(home, tmp_path, conv, live, spell): + """claude writes its ids in lowercase, and on macOS's default + case-insensitive APFS another spelling names the very same files. Every + check then uses the lowercase id: the live holder is seen, by its session + file or its command line, and a purge takes the session's own paths.""" + ws = tmp_path / "ws" + ws.mkdir() + t = _tree(home, ws, conv, tmp_path / "ctmp", pid=live.pid) + other = spell(conv) + assert other != conv + adapter = get_adapter("claude") + ctx = _ctx(ws, home) + + assert adapter.conversation_in_use(other, ctx) is True + for args in (["purge", "claude", other, str(ws)], ["in-use", "claude", other, str(ws)]): + res = _agents_cli(args, ws) + assert res.returncode == 4, res.stdout + res.stderr + assert all(p.exists() for p in t.values()) + + live.kill() + live.wait() + named = subprocess.Popen(["sh", "-c", "sleep 120", conv]) + try: + time.sleep(0.05) + assert adapter.conversation_in_use(other, ctx) is True, "a command line naming the lowercase id" + finally: + named.kill() + named.wait() + + assert adapter.has_artifacts(other, ctx) is True + purged = adapter.purge_artifacts(other, ctx) + assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()} + assert purged and all(other not in p for p in purged) + assert adapter.residual_artifacts(other, ctx) == [] + + def test_claude_purge_never_follows_a_linked_dir_below_a_root(home, tmp_path, conv): """A symlinked (tmp, projects) or file-history/ below its root is not followed: what lies behind it stays and is reported as residual."""