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:
1 parent
60779ba1f8
commit
c0248b88be
2 files changed
+60
-13
No files matched your search
@@ -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)
|
_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:
|
def _canonical(uuid) -> Optional[str]:
|
||||||
"""claude names every session by a canonical uuid. Any other name in a
|
"""The id claude itself writes, in lowercase, for any spelling of a
|
||||||
session's place is not one: projects/<key>/memory/ (auto memory) sits
|
canonical uuid; None for anything else. Any other name in a session's
|
||||||
beside the session dirs."""
|
place is not one: projects/<key>/memory/ (auto memory) sits beside the
|
||||||
return isinstance(uuid, str) and bool(_UUID.fullmatch(uuid))
|
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:
|
def _config_dir(ctx: DiscoveryContext) -> str:
|
||||||
@@ -306,14 +310,16 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
|||||||
# pre-#31 reach: the `<id>.jsonl` file alone.
|
# pre-#31 reach: the `<id>.jsonl` file alone.
|
||||||
if not _plausible_id(uuid):
|
if not _plausible_id(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, uuid)
|
base = os.path.join(pdir, ctx.ws_key, canon or uuid)
|
||||||
found += [(pdir, p) for p in ((base + ".jsonl", base) if _is_uuid(uuid) else (base + ".jsonl",))
|
found += [(pdir, p) for p in ((base + ".jsonl", base) if canon else (base + ".jsonl",))
|
||||||
if os.path.lexists(p)]
|
if os.path.lexists(p)]
|
||||||
if not _is_uuid(uuid):
|
if not canon:
|
||||||
return found
|
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)
|
||||||
@@ -344,15 +350,17 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
|||||||
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
|
# No claude process holds a name that is not a uuid; searching
|
||||||
# command lines for one ("a/b", "memory") only finds strangers.
|
# command lines for one ("a/b", "memory") only finds strangers.
|
||||||
if not _is_uuid(uuid):
|
canon = _canonical(uuid)
|
||||||
|
if not canon:
|
||||||
return False
|
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 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:
|
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"] \
|
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:
|
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||||
# Best effort: never raises (stop runs this inside its registry
|
# Best effort: never raises (stop runs this inside its registry
|
||||||
@@ -360,6 +368,7 @@ 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
|
||||||
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):
|
||||||
@@ -379,8 +388,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 _is_uuid(uuid):
|
if not _canonical(uuid):
|
||||||
return sorted(p for _, p in self._candidates(uuid, ctx))
|
return sorted(p for _, p in self._candidates(uuid, ctx))
|
||||||
|
uuid = _canonical(uuid)
|
||||||
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))
|
||||||
|
|||||||
@@ -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)
|
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):
|
def test_claude_purge_never_follows_a_linked_dir_below_a_root(home, tmp_path, conv):
|
||||||
"""A symlinked <key> (tmp, projects) or file-history/ below its root is
|
"""A symlinked <key> (tmp, projects) or file-history/ below its root is
|
||||||
not followed: what lies behind it stays and is reported as residual."""
|
not followed: what lies behind it stays and is reported as residual."""
|
||||||
|
|||||||
Reference in new issue
Block a user