From 6e182f96becf2b1a7cfb5111be767ec075199426 Mon Sep 17 00:00:00 2001 From: carryzuo00 Date: Mon, 25 May 2026 19:48:27 +0000 Subject: [PATCH] fix(disk-cleanup): restrict cron-output classification to output subtree The post_tool_call hook used guess_category() to auto-track every file under ~/.hermes/cron/ as "cron-output", which is later subject to age-based auto-deletion (>14d). That made the durable scheduler registry (cron/jobs.json) and the tick lock (cron/.tick.lock) eligible for removal; once jobs.json was deleted, Hermes treated the scheduler as having zero jobs. Only the per-run artefact subtree at cron/output/{job_id}/*.md is disposable. Limit the cron-output category to that subtree and leave top-level cron/ control-plane files untracked. Regression tests cover: cron/output//run.md -> "cron-output" cron/jobs.json -> None cron/.tick.lock -> None cron/ -> None Fixes #32164 --- plugins/disk-cleanup/disk_cleanup.py | 7 ++++- tests/plugins/test_disk_cleanup_plugin.py | 32 +++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/plugins/disk-cleanup/disk_cleanup.py b/plugins/disk-cleanup/disk_cleanup.py index b7f748e7f2104..e9e12beeaec04 100755 --- a/plugins/disk-cleanup/disk_cleanup.py +++ b/plugins/disk-cleanup/disk_cleanup.py @@ -481,7 +481,12 @@ def guess_category(path: Path) -> Optional[str]: }: return None if top == "cron" or top == "cronjobs": - return "cron-output" + # Only the per-run artefact subtree is disposable. Top-level + # files like jobs.json (the durable scheduler registry) and + # .tick.lock must never be auto-tracked for cleanup. + if len(rel.parts) >= 2 and rel.parts[1] == "output": + return "cron-output" + return None if top == "cache": return "temp" except ValueError: diff --git a/tests/plugins/test_disk_cleanup_plugin.py b/tests/plugins/test_disk_cleanup_plugin.py index e1463bced7ad7..a70e9b0c0e411 100644 --- a/tests/plugins/test_disk_cleanup_plugin.py +++ b/tests/plugins/test_disk_cleanup_plugin.py @@ -127,13 +127,41 @@ def test_skips_protected_top_level(self, _isolate_env): # Even though it matches test_* pattern, logs/ is excluded. assert dg.guess_category(p) is None - def test_cron_subtree_categorised(self, _isolate_env): + def test_cron_output_subtree_categorised(self, _isolate_env): + dg = _load_lib() + run_dir = _isolate_env / "cron" / "output" / "job-123" + run_dir.mkdir(parents=True) + p = run_dir / "run.md" + p.write_text("x") + assert dg.guess_category(p) == "cron-output" + + def test_cron_jobs_registry_not_categorised(self, _isolate_env): + """Regression: ~/.hermes/cron/jobs.json is the durable scheduler + registry and must never be auto-tracked as cleanup candidate.""" + dg = _load_lib() + cron_dir = _isolate_env / "cron" + cron_dir.mkdir() + p = cron_dir / "jobs.json" + p.write_text("[]") + assert dg.guess_category(p) is None + + def test_cron_tick_lock_not_categorised(self, _isolate_env): + """Regression: the scheduler's tick lock is control-plane state.""" + dg = _load_lib() + cron_dir = _isolate_env / "cron" + cron_dir.mkdir() + p = cron_dir / ".tick.lock" + p.write_text("") + assert dg.guess_category(p) is None + + def test_cron_top_level_file_not_categorised(self, _isolate_env): + """Any other top-level cron/ file should also be left alone.""" dg = _load_lib() cron_dir = _isolate_env / "cron" cron_dir.mkdir() p = cron_dir / "job_output.md" p.write_text("x") - assert dg.guess_category(p) == "cron-output" + assert dg.guess_category(p) is None def test_ordinary_file_returns_none(self, _isolate_env): dg = _load_lib()