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
@@ -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))
|
||||
|
||||
@@ -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 <key> (tmp, projects) or file-history/ below its root is
|
||||
not followed: what lies behind it stays and is reported as residual."""
|
||||
|
||||
Reference in new issue
Block a user