From 872d5f12fbe6be776ebb549347edd1b7a1b32340 Mon Sep 17 00:00:00 2001 From: Godopu Date: Thu, 15 Oct 2026 16:00:40 +0000 Subject: [PATCH] test(issue31): CLAUDE_CONFIG_DIR and partial-failure purges Config-only and config+project overrides: a live session is refused and a stopped one cleaned up. A key that cannot be removed keeps its session file; a job dir that cannot be emptied keeps state.json; both are reported and removed on retry. 5 of these fail on 8edb8d6. Co-Authored-By: Claude Opus 5.5 --- tests/test_issue31_purge_residual.py | 92 ++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/tests/test_issue31_purge_residual.py b/tests/test_issue31_purge_residual.py index be5d094..704ad86 100644 --- a/tests/test_issue31_purge_residual.py +++ b/tests/test_issue31_purge_residual.py @@ -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 /.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}"