diff --git a/plugins/disk-cleanup/disk_cleanup.py b/plugins/disk-cleanup/disk_cleanup.py index fddb62dacb06e..8f70631ea84f3 100755 --- a/plugins/disk-cleanup/disk_cleanup.py +++ b/plugins/disk-cleanup/disk_cleanup.py @@ -144,6 +144,17 @@ def save_tracked(tracked: List[Dict[str, Any]]) -> None: "chrome-profile", "cron-output", "other", } +_EMPTY_DIR_PROTECTED_TOP_LEVEL = frozenset({ + "logs", "memories", "sessions", "cron", "cronjobs", + "cache", "skills", "plugins", "disk-cleanup", "optional-skills", + "hermes-agent", "backups", "profiles", ".worktrees", +}) + +_EMPTY_DIR_SWEEP_PRUNE_DIRS = frozenset({ + ".git", "node_modules", "venv", ".venv", + "site-packages", "__pycache__", +}) + # Paths under $HERMES_HOME that must NEVER be deleted by quick(), # regardless of what the stored category says. This is a defense-in-depth @@ -348,27 +359,28 @@ def quick() -> Dict[str, Any]: else: new_tracked.append(item) - # Remove empty dirs under HERMES_HOME (but leave HERMES_HOME itself and - # a short list of well-known top-level state dirs alone — a fresh install - # has these empty, and deleting them would surprise the user). + # Remove empty dirs under HERMES_HOME, but never recurse into known + # durable state trees. Some installs place the Hermes checkout, venv, + # and desktop build under HERMES_HOME; a full rglob over that tree can + # stall the gateway event loop for minutes. hermes_home = get_hermes_home() - _PROTECTED_TOP_LEVEL = { - "logs", "memories", "sessions", "cron", "cronjobs", - "cache", "skills", "plugins", "disk-cleanup", "optional-skills", - "hermes-agent", "backups", "profiles", ".worktrees", - } empty_removed = 0 + sweep_stack: List[Tuple[Path, bool]] = [] try: - for dirpath in sorted(hermes_home.rglob("*"), reverse=True): - if not dirpath.is_dir() or dirpath == hermes_home: - continue - try: - rel_parts = dirpath.relative_to(hermes_home).parts - except ValueError: - continue - # Skip the well-known top-level state dirs themselves. - if len(rel_parts) == 1 and rel_parts[0] in _PROTECTED_TOP_LEVEL: - continue + for top in hermes_home.iterdir(): + if ( + top.is_dir() + and not top.is_symlink() + and top.name not in _EMPTY_DIR_PROTECTED_TOP_LEVEL + and top.name not in _EMPTY_DIR_SWEEP_PRUNE_DIRS + ): + sweep_stack.append((top, False)) + except OSError: + sweep_stack = [] + + while sweep_stack: + dirpath, visited = sweep_stack.pop() + if visited: try: if not any(dirpath.iterdir()): dirpath.rmdir() @@ -376,8 +388,19 @@ def quick() -> Dict[str, Any]: _log(f"DELETED: {dirpath} (empty dir)") except OSError: pass - except OSError: - pass + continue + + sweep_stack.append((dirpath, True)) + try: + for child in dirpath.iterdir(): + if ( + child.is_dir() + and not child.is_symlink() + and child.name not in _EMPTY_DIR_SWEEP_PRUNE_DIRS + ): + sweep_stack.append((child, False)) + except OSError: + pass save_tracked(new_tracked) _log( diff --git a/tests/plugins/test_disk_cleanup_plugin.py b/tests/plugins/test_disk_cleanup_plugin.py index 783644d3888de..52afddc9c46ab 100644 --- a/tests/plugins/test_disk_cleanup_plugin.py +++ b/tests/plugins/test_disk_cleanup_plugin.py @@ -352,6 +352,37 @@ def test_quick_preserves_protected_top_level_dirs(self, _isolate_env): for d in ("logs", "memories", "sessions", "cron", "cache"): assert (_isolate_env / d).exists(), f"{d}/ should be preserved" + def test_quick_does_not_descend_into_protected_top_level_dirs( + self, _isolate_env, monkeypatch + ): + dg = _load_lib() + protected_empty = ( + _isolate_env / "hermes-agent" / "node_modules" / "pkg" / "empty" + ) + protected_empty.mkdir(parents=True) + + original_iterdir = Path.iterdir + + def guarded_iterdir(path): + if path == _isolate_env / "hermes-agent": + raise AssertionError("quick() descended into protected hermes-agent/") + return original_iterdir(path) + + monkeypatch.setattr(Path, "iterdir", guarded_iterdir) + + dg.quick() + + assert protected_empty.exists() + + def test_quick_removes_empty_dirs_in_managed_subtrees(self, _isolate_env): + dg = _load_lib() + managed_empty = _isolate_env / "scratch" / "nested" / "empty" + managed_empty.mkdir(parents=True) + + dg.quick() + + assert not (_isolate_env / "scratch").exists() + class TestStatus: def test_empty_status(self, _isolate_env):