From dd292c12cbee0c0b340476de735fa8c317b7c20b Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 16 Oct 2026 04:07:49 +0000 Subject: [PATCH] fix(agents): a claude leftover behind a link still selects claude for its residual (Issue #31, review) has_artifacts counted only the paths a purge may remove. When all that was left lay behind a linked dir, `purge claude ` (or auto) found no adapter and exited 5 with an empty residual, hiding the path the purge had refused to follow. has_artifacts now counts every candidate; _inside still decides what is removed, so the CLI exits 0, removes nothing and lists the linked path as residual. Co-Authored-By: Claude Opus 5.5 --- .../skills/lib_py/agents/adapters/claude.py | 6 ++++-- tests/test_issue31_purge_residual.py | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.agents/skills/lib_py/agents/adapters/claude.py b/.agents/skills/lib_py/agents/adapters/claude.py index 480ed52..a093a46 100644 --- a/.agents/skills/lib_py/agents/adapters/claude.py +++ b/.agents/skills/lib_py/agents/adapters/claude.py @@ -344,8 +344,10 @@ class ClaudeAgentAdapter(BaseAgentAdapter): return [p for root, p in self._candidates(uuid, ctx) if _inside(root, p)] def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool: - # Leftovers count without the transcript (a pre-#31 purge left them). - return bool(self._owned_paths(uuid, ctx)) + # Leftovers count without the transcript (a pre-#31 purge left them), + # and so does one behind a link: the purge keeps it, but picking + # claude is what gets it reported as residual. + return bool(self._candidates(uuid, ctx)) def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool: # No claude process holds a name that is not a uuid; searching diff --git a/tests/test_issue31_purge_residual.py b/tests/test_issue31_purge_residual.py index 5d7bca5..b48590f 100644 --- a/tests/test_issue31_purge_residual.py +++ b/tests/test_issue31_purge_residual.py @@ -355,6 +355,26 @@ def test_claude_purge_never_follows_a_linked_dir_below_a_root(home, tmp_path, co str(cfg / "projects" / key / conv), str(cfg / "file-history" / conv)]) +def test_purge_cli_reports_a_linked_orphan_it_will_not_remove(home, tmp_path, conv): + """A candidate behind a link is all that is left: the CLI still picks + claude, so its residual names it, and removes nothing.""" + ws = tmp_path / "ws" + ws.mkdir() + keep = tmp_path / "outside" / conv / "keep.txt" + keep.parent.mkdir(parents=True) + keep.write_text("keep") + troot = tmp_path / "ctmp" / f"claude-{UID}" + troot.mkdir(parents=True) + (troot / "linked-workspace").symlink_to(tmp_path / "outside") + + res = _agents_cli(["purge", "claude", conv, str(ws)], ws) + + assert res.returncode == 0, res.stdout + res.stderr + assert json.loads(res.stdout) == {"agent": "claude", "purged": [], + "residual": [str(troot / "linked-workspace" / conv)], "in_use": False} + assert keep.exists() + + def test_claude_purge_removes_only_the_link_at_the_leaf(home, tmp_path, conv): ws = tmp_path / "ws" ws.mkdir()