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

Merged
Godopu merged 26 commits from fix/issue-31 into main 2026-10-17 16:25:21 +00:00
Showing only changes of commit 872d5f12fb - Show all commits
+88 -4
View File
@@ -103,9 +103,10 @@ def _session_file(cfg: Path, pid: int, sid: str, started_ms=None, key=True) -> l
return made
def _tree(h: Path, ws: Path, sid: str, tmp_root: Path, pid=None) -> dict:
"""Everything a claude session `sid` owns, as claude 2.1 lays it out."""
cfg = h / ".claude"
def _tree(h: Path, ws: Path, sid: str, tmp_root: Path, pid=None, cfg=None) -> dict:
"""Everything a claude session `sid` owns, as claude 2.1 lays it out
(`cfg`: a CLAUDE_CONFIG_DIR root instead of <home>/.claude)."""
cfg = cfg or h / ".claude"
key = workspace_key(str(ws))
proj = cfg / "projects" / key
proj.mkdir(parents=True, exist_ok=True)
@@ -306,8 +307,11 @@ def test_session_file_liveness_fails_safe(monkeypatch, live):
assert claude_mod._session_file_live({"startedAt": now_ms}, _dead_pid()) is False
assert claude_mod._session_file_live({"startedAt": now_ms}, None) is True
assert claude_mod._session_file_live({"startedAt": now_ms}, 0) is True
assert claude_mod._session_file_live({"startedAt": 0}, live.pid) is True, "no usable startedAt: live"
hour_ago = int((time.time() - 3600) * 1000)
assert claude_mod._session_file_live({"startedAt": hour_ago}, live.pid) is False, "reused pid"
monkeypatch.setenv("PATH", "/nonexistent")
assert claude_mod._session_file_live({"startedAt": 0}, live.pid) is True, "ps unavailable: live"
assert claude_mod._session_file_live({"startedAt": hour_ago}, live.pid) is True, "ps unavailable: live"
def test_claude_purge_takes_the_stale_key_with_its_session_file(home, tmp_path, conv, other, live):
@@ -327,6 +331,86 @@ def test_claude_purge_takes_the_stale_key_with_its_session_file(home, tmp_path,
assert set(purged) == {str(stale[0]), _masked(stale[1])}
@pytest.mark.parametrize("project_override", [False, True])
def test_claude_config_dir_moves_the_config_root(home, tmp_path, monkeypatch, conv, live, project_override):
"""CLAUDE_CONFIG_DIR moves sessions/, file-history/, ... and projects/:
a live session there is refused, a stopped one is cleaned up. With
CLAUDE_PROJECT_DIR naming the same projects/ the result is the same."""
ws = tmp_path / "ws"
ws.mkdir()
cfg = tmp_path / "cfg"
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(cfg))
if project_override:
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(cfg / "projects"))
else:
monkeypatch.delenv("CLAUDE_PROJECT_DIR")
t = _tree(home, ws, conv, tmp_path / "ctmp", pid=live.pid, cfg=cfg)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.conversation_in_use(conv, ctx) is True
res = _agents_cli(["purge", "claude", conv, str(ws)], ws)
assert res.returncode == 4, res.stdout + res.stderr
assert all(p.exists() for p in t.values())
live.kill()
live.wait()
purged = adapter.purge_artifacts(conv, ctx)
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
assert str(t["file_history"]) in purged and str(t["jsonl"]) in purged
assert adapter.residual_artifacts(conv, ctx) == []
assert not (home / ".claude" / "file-history").exists(), "the default root is not used"
def _failing_remove(monkeypatch, victim: Path):
from lib_py.agents.adapters import claude as claude_mod
real = os.remove
def remove(path, *a, **kw):
if os.path.realpath(path) == os.path.realpath(str(victim)):
raise PermissionError(13, "denied", str(path))
return real(path, *a, **kw)
monkeypatch.setattr(claude_mod.os, "remove", remove)
def test_a_key_that_cannot_be_removed_keeps_its_session_file(home, tmp_path, monkeypatch, conv):
ws = tmp_path / "ws"
ws.mkdir()
sfile, key = _session_file(home / ".claude", _dead_pid(), conv)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
with monkeypatch.context() as m:
_failing_remove(m, key)
purged = adapter.purge_artifacts(conv, ctx)
assert key.exists() and sfile.exists(), "the session file is what ties the key to this session"
assert purged == []
assert adapter.residual_artifacts(conv, ctx) == sorted([str(sfile), _masked(key)])
assert set(adapter.purge_artifacts(conv, ctx)) == {_masked(key), str(sfile)}
assert adapter.residual_artifacts(conv, ctx) == []
def test_a_job_dir_that_cannot_be_emptied_keeps_its_state_json(home, tmp_path, monkeypatch, conv):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp")
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
with monkeypatch.context() as m:
_failing_remove(m, t["job"] / "timeline.jsonl")
purged = adapter.purge_artifacts(conv, ctx)
assert (t["job"] / "state.json").exists(), "state.json proves the dir is this session's"
assert str(t["job"]) not in purged
assert not t["file_history"].exists()
assert adapter.residual_artifacts(conv, ctx) == [str(t["job"])]
assert adapter.purge_artifacts(conv, ctx) == [str(t["job"])]
assert not t["job"].exists()
assert adapter.residual_artifacts(conv, ctx) == []
def test_claude_tmp_root_rules(tmp_path, monkeypatch):
from lib_py.agents.adapters.claude import _tmp_roots
name = f"claude-{UID}"