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 <UPPER>`
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 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-16 04:06:37 +00:00
1 parent 60779ba1f8
commit c0248b88be
2 files changed
+60 -13

No files matched your search

+23 -13
View File
@@ -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/<key>/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/<key>/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 `<id>.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))