{"repo":"NousResearch/hermes-agent","public_id":"32164","display_label":"disk-cleanup incorrectly deleting cron/jobs.json registry","summary":"Multiple issues and proposed fixes addressing a bug where disk-cleanup misclassifies cron/jobs.json as cron-output and may delete it. The fixes focus on restricting cron-output classification to the correct subtree and revalidating stale entries before deletion.","subsystem":"cron","counts":{"members":13,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34840},"resolution":{"state":"resolved","by":"pr-34840,pr-39067"},"freshness":{"state":"verified","last_verified_at":"2026-06-04T14:52:05+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14564102564102563},"fix_edges":[{"issue_number":32164,"pr_number":34840,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":34600,"pr_number":34840,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":37721,"pr_number":39067,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":32164,"title":"disk-cleanup misclassifies cron/jobs.json as cron-output and can delete the live cron registry","url":null,"state":"closed"},{"kind":"issue","number":37721,"title":"[Bug]: disk-cleanup can still delete cron/jobs.json from stale tracked.json entries","url":null,"state":"closed"},{"kind":"pr","number":32177,"title":"fix(plugins): preserve cron registry from disk-cleanup auto-categorisation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32164","number":32164}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":32222,"title":"fix(disk-cleanup): restrict cron-output classification to output subtree","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32177","number":32177}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":32436,"title":"fix(disk-cleanup): protect cron/jobs.json from deletion","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34840","number":34840},{"kind":"pr","value":"#32164","number":32164},{"kind":"pr","value":"#33834","number":33834}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":32478,"title":"fix(plugins): restrict disk-cleanup cron-output classification to output/ subtree (#32164)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34840","number":34840},{"kind":"pr","value":"#33834","number":33834},{"kind":"pr","value":"#32164","number":32164}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":33834,"title":"fix(cron): exclude jobs.json registry from disk-cleanup pattern","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d473e7c93","number":0},{"kind":"pr","value":"#34840","number":34840}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":34840,"title":"fix(cron): stop silent cron-job loss across update (disk-cleanup + snapshot restore)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/backup.py","hermes_cli/main.py","plugins/disk-cleanup/disk_cleanup.py","scripts/release.py","tests/hermes_cli/test_backup.py","tests/plugins/test_disk_cleanup_plugin.py"],"fix_hunks":[{"file":"hermes_cli/backup.py","hunks":["@@ -670,6 +670,105 @@ def restore_quick_snapshot(\n     return restored > 0\n \n \n+# Relative path of the cron job database inside HERMES_HOME. Kept in sync with\n+# the entry in ``_QUICK_STATE_FILES`` and with ``cron/jobs.py``'s ``JOBS_FILE``.\n+_CRON_JOBS_REL = \"cron/jobs.json\"\n+\n+\n+def _count_cron_jobs(path: Path) -> Optional[int]:\n+    \"\"\"Return the number of cron jobs stored in ``path``.\n+\n+    The canonical on-disk shape is ``{\"jobs\": [...]}`` (see ``cron/jobs.py``).\n+    A legacy bare-list shape (``[...]``) is also honoured.\n+\n+    Returns:\n+        The job count for any *valid, readable* JSON document, or ``None`` if\n+        the file is missing or cannot be parsed. ``None`` means \"unknown\" —\n+        callers must not treat it as \"zero jobs\", because acting on an\n+        unreadable file could mask a real corruption the user needs to see.\n+    \"\"\"\n+    if not path.is_file():\n+        return None\n+    try:\n+        with open(path, \"r\", encoding=\"utf-8\") as f:\n+            data = json.load(f)\n+    except (OSError, json.JSONDecodeError):\n+        return None\n+    if isinstance(data, dict):\n+        jobs = data.get(\"jobs\", [])\n+        return len(jobs) if isinstance(jobs, list) else None\n+    if isinstance(data, list):\n+        return len(data)\n+    return None\n+\n+\n+def restore_cron_jobs_if_emptied(\n+    snapshot_id: str,\n+    hermes_home: Optional[Path] = None,\n+) -> Optional[Dict[str, Any]]:\n+    \"\"\"Safety net for silent cron-job loss across ``hermes update``.\n+\n+    Config-version migrations have been observed to leave ``cron/jobs.json``\n+    valid-but-empty after an update, silently dropping every scheduled job\n+    (issue #34600). The existing malformed-shape guards in ``cron/jobs.py``\n+    don't catch this case because ``{\"jobs\": []}`` is perfectly valid JSON.\n+\n+    This compares the *current* job count against the pre-update snapshot. If\n+    the live file now has **zero** jobs while the snapshot captured **one or\n+    more**, the snapshot copy of ``cron/jobs.json`` is restored in place.\n+\n+    The check is deliberately conservative — it only ever restores when there\n+    is unambiguous evidence of loss (snapshot had jobs, live file has none),\n+    so a user who genuinely deleted all their jobs during/after the update is\n+    never second-guessed, and an unreadable live file (count ``None``) is left\n+    untouched so real corruption still surfaces.\n+\n+    Args:\n+        snapshot_id: The pre-update quick-snapshot id (from\n+            :func:`create_quick_snapshot`).\n+        hermes_home: Override for the Hermes home directory (tests).\n+\n+    Returns:\n+        ``None`` when no action was taken (the common, healthy path). On a\n+        successful restore, a dict ``{\"restored\": True, \"job_count\": N,\n+        \"snapshot_id\": ...}`` so the caller can warn the user.\n+    \"\"\"\n+    if not snapshot_id:\n+        return None\n+\n+    home = hermes_home or get_hermes_home()\n+    live_path = home / _CRON_JOBS_REL\n+\n+    live_count = _count_cron_jobs(live_path)\n+    # Only act when the live file is readable AND empty. ``None`` (missing or\n+    # unparseable) is intentionally left alone — that's a different failure\n+    # mode the user should see rather than have papered over.\n+    if live_count is None or live_count > 0:\n+        return None\n+\n+    snap_path = _quick_snapshot_root(home) / snapshot_id / _CRON_JOBS_REL\n+    snap_count = _count_cron_jobs(snap_path)\n+    if not snap_count:  # None or 0 — nothing worth restoring\n+        return None\n+\n+    try:\n+        live_path.parent.mkdir(parents=True, exist_ok=True)\n+        shutil.copy2(snap_path, live_path)\n+    except (OSError, PermissionError) as exc:\n+        logger.error(\n+            \"Cron jobs were emptied during update but auto-restore failed: %s\", exc\n+        )\n+        return None\n+\n+    logger.warning(\n+        \"Restored %d cron job(s) from pre-update snapshot %s \"\n+        \"(cron/jobs.json was emptied during migration)\",\n+        snap_count,\n+        snapshot_id,\n+    )\n+    return {\"restored\": True, \"job_count\": snap_count, \"snapshot_id\": snapshot_id}\n+\n+\n def _prune_quick_snapshots(root: Path, keep: int = _QUICK_DEFAULT_KEEP) -> int:\n     \"\"\"Remove oldest quick snapshots beyond the keep limit. Returns count deleted.\"\"\"\n     if not root.exists():"]},{"file":"hermes_cli/main.py","hunks":["@@ -9125,12 +9125,13 @@ def _cmd_update_impl(args, gateway_mode: bool):\n         # though `git pull` can't touch $HERMES_HOME, this is cheap\n         # belt-and-suspenders insurance and gives the user something to\n         # restore from via `/snapshot list` / `/snapshot restore <id>`.\n+        pre_update_snapshot_id = None\n         try:\n             from hermes_cli.backup import create_quick_snapshot\n \n-            snap_id = create_quick_snapshot(label=\"pre-update\", keep=1)\n-            if snap_id:\n-                print(f\"  ✓ Pre-update snapshot: {snap_id}\")\n+            pre_update_snapshot_id = create_quick_snapshot(label=\"pre-update\", keep=1)\n+            if pre_update_snapshot_id:\n+                print(f\"  ✓ Pre-update snapshot: {pre_update_snapshot_id}\")\n         except Exception as exc:\n             # Never let a snapshot failure block an update.\n             logger.debug(\"Pre-update snapshot failed: %s\", exc)","@@ -9467,6 +9468,25 @@ def _cmd_update_impl(args, gateway_mode: bool):\n         else:\n             print(\"  ✓ Configuration is up to date\")\n \n+        # Safety net: config-version migrations have been observed to leave\n+        # cron/jobs.json valid-but-empty, silently dropping every scheduled\n+        # job (issue #34600). If the live file is now empty while the\n+        # pre-update snapshot held jobs, restore it and warn loudly.\n+        try:\n+            from hermes_cli.backup import restore_cron_jobs_if_emptied\n+\n+            cron_restore = restore_cron_jobs_if_emptied(pre_update_snapshot_id)\n+            if cron_restore:\n+                print()\n+                print(\n+                    \"  ⚠️  cron/jobs.json was emptied during this update — \"\n+                    f\"restored {cron_restore['job_count']} job(s) from \"\n+                    f\"pre-update snapshot {cron_restore['snapshot_id']}.\"\n+                )\n+        except Exception as exc:\n+            # Never let the cron safety net break an otherwise-good update.\n+            logger.debug(\"Cron jobs auto-restore check failed: %s\", exc)\n+\n         print()\n         print(\"✓ Update complete!\")\n "]},{"file":"plugins/disk-cleanup/disk_cleanup.py","hunks":["@@ -481,7 +481,14 @@ def guess_category(path: Path) -> Optional[str]:\n         }:\n             return None\n         if top == \"cron\" or top == \"cronjobs\":\n-            return \"cron-output\"\n+            # Only files under the disposable ``output/`` subtree are\n+            # cleanup candidates. Top-level cron control-plane state\n+            # (e.g. ``jobs.json``, ``.tick.lock``) must never be\n+            # auto-tracked — deleting it wipes the live scheduler\n+            # registry. See issue #32164.\n+            if len(rel.parts) >= 2 and rel.parts[1] == \"output\":\n+                return \"cron-output\"\n+            return None\n         if top == \"cache\":\n             return \"temp\"\n     except ValueError:"]},{"file":"scripts/release.py","hunks":["@@ -645,6 +645,7 @@\n     \"[redacted:email]\": \"AntAISecurityLab\",\n     \"[redacted:email]\": \"briandevans\",\n     \"[redacted:email]\": \"Bartok9\",\n+    \"[redacted:email]\": \"sweetcornna\",\n     \"[redacted:email]\": \"CruxExperts\",\n     \"[redacted:email]\": \"LeonSGP43\",\n     \"[redacted:email]\": \"Kailigithub\","]},{"file":"tests/hermes_cli/test_backup.py","hunks":["@@ -1679,3 +1679,105 @@ def test_does_not_touch_pre_update_backups(self, hermes_home):\n             _t.sleep(1.05)\n         # Update backup must still be there\n         assert update_backup.exists(), \"pre-migration rotation wrongly pruned the pre-update backup\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Cron jobs auto-restore after silent migration loss (issue #34600)\n+# ---------------------------------------------------------------------------\n+\n+class TestRestoreCronJobsIfEmptied:\n+    \"\"\"`hermes update` config migration can leave cron/jobs.json valid-but-empty,\n+    silently dropping every scheduled job. `restore_cron_jobs_if_emptied` is the\n+    post-migration safety net that restores from the pre-update snapshot.\"\"\"\n+\n+    @staticmethod\n+    def _seed_jobs(path: Path, jobs):\n+        path.parent.mkdir(parents=True, exist_ok=True)\n+        path.write_text(json.dumps({\"jobs\": jobs}))\n+\n+    def _make_snapshot(self, hermes_home: Path, label=\"pre-update\"):\n+        from hermes_cli.backup import create_quick_snapshot\n+        return create_quick_snapshot(label=label, hermes_home=hermes_home, keep=5)\n+\n+    def test_restores_when_emptied_after_migration(self, tmp_path):\n+        from hermes_cli.backup import restore_cron_jobs_if_emptied\n+        hermes_home = tmp_path / \".hermes\"\n+        jobs_path = hermes_home / \"cron\" / \"jobs.json\"\n+        # Pre-update: 3 real jobs.\n+        self._seed_jobs(jobs_path, [{\"id\": \"a\"}, {\"id\": \"b\"}, {\"id\": \"c\"}])\n+        snap_id = self._make_snapshot(hermes_home)\n+        assert snap_id\n+\n+        # Migration silently empties the file (valid JSON, zero jobs).\n+        jobs_path.write_text(json.dumps({\"jobs\": []}))\n+\n+        result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)\n+        assert result is not None\n+        assert result[\"restored\"] is True\n+        assert result[\"job_count\"] == 3\n+        assert result[\"snapshot_id\"] == snap_id\n+\n+        # The live file now has the jobs back.\n+        restored = json.loads(jobs_path.read_text())\n+        assert len(restored[\"jobs\"]) == 3\n+\n+    def test_noop_when_live_file_still_has_jobs(self, tmp_path):\n+        from hermes_cli.backup import restore_cron_jobs_if_emptied\n+        hermes_home = tmp_path / \".hermes\"\n+        jobs_path = hermes_home / \"cron\" / \"jobs.json\"\n+        self._seed_jobs(jobs_path, [{\"id\": \"a\"}, {\"id\": \"b\"}])\n+        snap_id = self._make_snapshot(hermes_home)\n+\n+        # Healthy path: file unchanged after update.\n+        result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)\n+        assert result is None\n+\n+    def test_noop_when_snapshot_had_no_jobs(self, tmp_path):\n+        from hermes_cli.backup import restore_cron_jobs_if_emptied\n+        hermes_home = tmp_path / \".hermes\"\n+        jobs_path = hermes_home / \"cron\" / \"jobs.json\"\n+        # Pre-update genuinely had zero jobs; current is also empty.\n+        self._seed_jobs(jobs_path, [])\n+        snap_id = self._make_snapshot(hermes_home)\n+        jobs_path.write_text(json.dumps({\"jobs\": []}))\n+\n+        result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)\n+        assert result is None\n+\n+    def test_noop_when_live_file_unreadable(self, tmp_path):\n+        \"\"\"An unparseable live file is left alone — that's a different failure\n+        mode the user should see, not silently overwrite.\"\"\"\n+        from hermes_cli.backup import restore_cron_jobs_if_emptied\n+        hermes_home = tmp_path / \".hermes\"\n+        jobs_path = hermes_home / \"cron\" / \"jobs.json\"\n+        self._seed_jobs(jobs_path, [{\"id\": \"a\"}])\n+        snap_id = self._make_snapshot(hermes_home)\n+        jobs_path.write_text(\"{ this is not valid json\")\n+\n+        result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)\n+        assert result is None\n+        # File left untouched.\n+        assert jobs_path.read_text() == \"{ this is not valid json\"\n+\n+    def test_noop_when_snapshot_id_missing(self, tmp_path):\n+        from hermes_cli.backup import restore_cron_jobs_if_emptied\n+        hermes_home = tmp_path / \".hermes\"\n+        jobs_path = hermes_home / \"cron\" / \"jobs.json\"\n+        self._seed_jobs(jobs_path, [])\n+        assert restore_cron_jobs_if_emptied(None, hermes_home=hermes_home) is None\n+        assert restore_cron_jobs_if_emptied(\"\", hermes_home=hermes_home) is None\n+\n+    def test_restores_legacy_bare_list_snapshot_shape(self, tmp_path):\n+        \"\"\"A legacy snapshot storing a bare JSON list (not {\"jobs\": [...]}) is\n+        still counted and restored.\"\"\"\n+        from hermes_cli.backup import restore_cron_jobs_if_emptied\n+        hermes_home = tmp_path / \".hermes\"\n+        jobs_path = hermes_home / \"cron\" / \"jobs.json\"\n+        jobs_path.parent.mkdir(parents=True, exist_ok=True)\n+        jobs_path.write_text(json.dumps([{\"id\": \"a\"}, {\"id\": \"b\"}]))\n+        snap_id = self._make_snapshot(hermes_home)\n+\n+        jobs_path.write_text(json.dumps({\"jobs\": []}))\n+        result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)\n+        assert result is not None\n+        assert result[\"job_count\"] == 2"]},{"file":"tests/plugins/test_disk_cleanup_plugin.py","hunks":["@@ -129,12 +129,40 @@ def test_skips_protected_top_level(self, _isolate_env):\n \n     def test_cron_subtree_categorised(self, _isolate_env):\n         dg = _load_lib()\n-        cron_dir = _isolate_env / \"cron\"\n-        cron_dir.mkdir()\n-        p = cron_dir / \"job_output.md\"\n+        # Only files under ``cron/output/`` are disposable run artifacts.\n+        output_dir = _isolate_env / \"cron\" / \"output\" / \"job_123\"\n+        output_dir.mkdir(parents=True)\n+        p = output_dir / \"run.md\"\n         p.write_text(\"x\")\n         assert dg.guess_category(p) == \"cron-output\"\n \n+    def test_cron_jobs_json_not_tracked(self, _isolate_env):\n+        \"\"\"Regression for #32164: the cron registry must never be tracked.\"\"\"\n+        dg = _load_lib()\n+        cron_dir = _isolate_env / \"cron\"\n+        cron_dir.mkdir()\n+        p = cron_dir / \"jobs.json\"\n+        p.write_text(\"[]\")\n+        assert dg.guess_category(p) is None\n+\n+    def test_cron_tick_lock_not_tracked(self, _isolate_env):\n+        \"\"\"Regression for #32164: cron tick-lock is control-plane state.\"\"\"\n+        dg = _load_lib()\n+        cron_dir = _isolate_env / \"cron\"\n+        cron_dir.mkdir()\n+        p = cron_dir / \".tick.lock\"\n+        p.write_text(\"\")\n+        assert dg.guess_category(p) is None\n+\n+    def test_cronjobs_top_level_not_tracked(self, _isolate_env):\n+        \"\"\"The legacy ``cronjobs`` alias is also control-plane at the top.\"\"\"\n+        dg = _load_lib()\n+        cron_dir = _isolate_env / \"cronjobs\"\n+        cron_dir.mkdir()\n+        p = cron_dir / \"jobs.json\"\n+        p.write_text(\"[]\")\n+        assert dg.guess_category(p) is None\n+\n     def test_ordinary_file_returns_none(self, _isolate_env):\n         dg = _load_lib()\n         p = _isolate_env / \"notes.md\"\n"]}]},{"kind":"pr","number":37726,"title":"fix(disk-cleanup): revalidate cron-output paths before quick() deletes them","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"30412a9771cc81861c58a94fdb64fc49036ef307","number":0},{"kind":"pr","value":"#37721","number":37721}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":37760,"title":"fix(cleanup): revalidate cron-output paths before deletion in quick()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"30412a9771cc81861c58a94fdb64fc49036ef307","number":0},{"kind":"pr","value":"#37721","number":37721},{"kind":"pr","value":"#37726","number":37726},{"kind":"pr","value":"#37721","number":37721}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":37868,"title":"fix(cron): re-validate stale cron-output entries before deletion #37721","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"30412a977","number":0},{"kind":"pr","value":"#39067","number":39067},{"kind":"pr","value":"#34840","number":34840}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":38713,"title":"fix(plugins): guard disk-cleanup against deleting cron/jobs.json (#37721)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"30412a977","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":39067,"title":"fix(cron): re-validate stale cron-output entries before deletion","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/disk-cleanup/disk_cleanup.py","tests/plugins/test_disk_cleanup_plugin.py"],"fix_hunks":[{"file":"plugins/disk-cleanup/disk_cleanup.py","hunks":["@@ -145,6 +145,33 @@ def save_tracked(tracked: List[Dict[str, Any]]) -> None:\n }\n \n \n+# Paths under $HERMES_HOME that must NEVER be deleted by quick(),\n+# regardless of what the stored category says.  This is a defense-in-depth\n+# guard against stale tracked.json entries from before #34840.\n+_PROTECTED_CRON_PATHS: set[str] = set()\n+\n+\n+def _is_protected_cron_path(p: Path) -> bool:\n+    \"\"\"Return True if *p* is a cron control-plane file/directory that must\n+    never be deleted.\n+\n+    This only matches the directory itself and known control-plane files\n+    (``jobs.json``, ``.tick.lock``) — it does NOT blanket-protect\n+    everything under ``cron/`` because ``cron/output/`` is disposable.\n+    \"\"\"\n+    # Lazily build the set once per process so HERMES_HOME is resolved\n+    # exactly once.\n+    if not _PROTECTED_CRON_PATHS:\n+        hermes_home = get_hermes_home()\n+        for parent in (\"cron\", \"cronjobs\"):\n+            base = hermes_home / parent\n+            _PROTECTED_CRON_PATHS.add(str(base))\n+            _PROTECTED_CRON_PATHS.add(str(base / \"jobs.json\"))\n+            _PROTECTED_CRON_PATHS.add(str(base / \".tick.lock\"))\n+    resolved = str(p.resolve())\n+    return resolved in _PROTECTED_CRON_PATHS\n+\n+\n def fmt_size(n: float) -> str:\n     for unit in (\"B\", \"KB\", \"MB\", \"GB\", \"TB\"):\n         if n < 1024:","@@ -226,6 +253,14 @@ def dry_run() -> Tuple[List[Dict], List[Dict]]:\n         cat = item[\"category\"]\n         size = item[\"size\"]\n \n+        # Re-validate stale \"cron-output\" entries (fixes #37721).\n+        if cat == \"cron-output\":\n+            re_cat = guess_category(p)\n+            if re_cat != \"cron-output\":\n+                # Stale entry — would be skipped by quick(); omit from\n+                # dry-run output too.\n+                continue\n+\n         if cat == \"test\":\n             auto.append(item)\n         elif cat == \"temp\" and age > 7:","@@ -269,6 +304,28 @@ def quick() -> Dict[str, Any]:\n \n         age = (now - datetime.fromisoformat(item[\"timestamp\"])).days\n \n+        # ---- stale-state migration (fixes #37721) ----\n+        # Old tracked.json entries may carry a \"cron-output\" category for\n+        # paths that are NOT under cron/output/ (e.g. cron/jobs.json).\n+        # guess_category() was fixed in #34840, but existing entries are\n+        # never re-validated.  Re-classify here so stale entries for cron\n+        # control-plane state are not deleted.\n+        if cat == \"cron-output\":\n+            re_cat = guess_category(p)\n+            if re_cat != \"cron-output\":\n+                _log(\n+                    f\"SKIP stale cron-output entry: {p} \"\n+                    f\"(re-classified as {re_cat!r})\"\n+                )\n+                # Drop the stale entry — it was misclassified.\n+                continue\n+\n+        # Hard safety net: never delete cron control-plane state even if\n+        # the category somehow slipped through re-validation above.\n+        if _is_protected_cron_path(p):\n+            _log(f\"SKIP protected cron path: {p}\")\n+            continue\n+\n         should_delete = (\n             cat == \"test\"\n             or (cat == \"temp\" and age > 7)"]},{"file":"tests/plugins/test_disk_cleanup_plugin.py","hunks":["@@ -170,6 +170,135 @@ def test_ordinary_file_returns_none(self, _isolate_env):\n         assert dg.guess_category(p) is None\n \n \n+class TestStaleCronEntryMigration:\n+    \"\"\"Regression tests for #37721 — stale cron-output entries in tracked.json.\"\"\"\n+\n+    def test_quick_skips_stale_cron_output_for_jobs_json(self, _isolate_env):\n+        \"\"\"A stale tracked.json entry with category=\"cron-output\" for\n+        cron/jobs.json must NOT be deleted by quick().\n+\n+        This is the exact scenario from #37721: an old tracked.json has\n+        {\"path\": \".../cron/jobs.json\", \"category\": \"cron-output\"} which\n+        would pass the delete filter but must be skipped because\n+        guess_category() now returns None for non-output cron paths.\n+        \"\"\"\n+        dg = _load_lib()\n+        cron_dir = _isolate_env / \"cron\"\n+        cron_dir.mkdir()\n+        jobs_json = cron_dir / \"jobs.json\"\n+        jobs_json.write_text('{\"jobs\": []}')\n+\n+        # Simulate a stale tracked.json entry from before #34840 by\n+        # directly writing the tracked file (track() would reject it).\n+        tracked_file = _isolate_env / \"disk-cleanup\" / \"tracked.json\"\n+        tracked_file.parent.mkdir(parents=True, exist_ok=True)\n+        tracked_file.write_text(json.dumps([{\n+            \"path\": str(jobs_json),\n+            \"category\": \"cron-output\",\n+            \"timestamp\": \"2025-01-01T00:00:00+00:00\",  # very old\n+            \"size\": 123,\n+        }]))\n+\n+        summary = dg.quick()\n+        assert summary[\"deleted\"] == 0, \"cron/jobs.json must not be deleted\"\n+        assert jobs_json.exists(), \"jobs.json must still exist\"\n+        # The stale entry should have been dropped from tracking.\n+        remaining = json.loads(tracked_file.read_text())\n+        assert len(remaining) == 0\n+\n+    def test_quick_skips_stale_cron_output_for_cron_dir(self, _isolate_env):\n+        \"\"\"Stale entry for the cron/ directory itself must not be deleted.\"\"\"\n+        dg = _load_lib()\n+        cron_dir = _isolate_env / \"cron\"\n+        cron_dir.mkdir()\n+        output_dir = cron_dir / \"output\"\n+        output_dir.mkdir()\n+        (output_dir / \"run.md\").write_text(\"x\")\n+\n+        tracked_file = _isolate_env / \"disk-cleanup\" / \"tracked.json\"\n+        tracked_file.parent.mkdir(parents=True, exist_ok=True)\n+        tracked_file.write_text(json.dumps([{\n+            \"path\": str(cron_dir),\n+            \"category\": \"cron-output\",\n+            \"timestamp\": \"2025-01-01T00:00:00+00:00\",\n+            \"size\": 0,\n+        }]))\n+\n+        summary = dg.quick()\n+        assert summary[\"deleted\"] == 0, \"cron/ dir must not be deleted\"\n+        assert cron_dir.exists()\n+\n+    def test_quick_skips_protected_cron_paths_defense_in_depth(self, _isolate_env):\n+        \"\"\"Defense-in-depth: even if guess_category returned cron-output\n+        (hypothetically), protected cron paths are never deleted.\"\"\"\n+        dg = _load_lib()\n+        cron_dir = _isolate_env / \"cron\"\n+        cron_dir.mkdir()\n+        tick_lock = cron_dir / \".tick.lock\"\n+        tick_lock.write_text(\"\")\n+\n+        # Manually inject a stale entry with \"test\" category (would normally\n+        # be auto-deleted) — the protected path guard must still block it.\n+        tracked_file = _isolate_env / \"disk-cleanup\" / \"tracked.json\"\n+        tracked_file.parent.mkdir(parents=True, exist_ok=True)\n+        tracked_file.write_text(json.dumps([{\n+            \"path\": str(tick_lock),\n+            \"category\": \"test\",\n+            \"timestamp\": \"2025-01-01T00:00:00+00:00\",\n+            \"size\": 0,\n+        }]))\n+\n+        summary = dg.quick()\n+        assert summary[\"deleted\"] == 0, \".tick.lock must not be deleted\"\n+        assert tick_lock.exists()\n+\n+    def test_dry_run_omits_stale_cron_output(self, _isolate_env):\n+        \"\"\"dry_run() should also skip stale cron-output entries.\"\"\"\n+        dg = _load_lib()\n+        cron_dir = _isolate_env / \"cron\"\n+        cron_dir.mkdir()\n+        jobs_json = cron_dir / \"jobs.json\"\n+        jobs_json.write_text(\"[]\")\n+\n+        tracked_file = _isolate_env / \"disk-cleanup\" / \"tracked.json\"\n+        tracked_file.parent.mkdir(parents=True, exist_ok=True)\n+        tracked_file.write_text(json.dumps([{\n+            \"path\": str(jobs_json),\n+            \"category\": \"cron-output\",\n+            \"timestamp\": \"2025-01-01T00:00:00+00:00\",\n+            \"size\": 123,\n+        }]))\n+\n+        auto, prompt = dg.dry_run()\n+        assert len(auto) == 0, \"stale cron-output for jobs.json must not appear\"\n+        assert len(prompt) == 0\n+\n+    def test_legitimate_cron_output_still_deleted(self, _isolate_env):\n+        \"\"\"A valid cron-output entry under cron/output/ must still be deleted.\"\"\"\n+        dg = _load_lib()\n+        output_dir = _isolate_env / \"cron\" / \"output\" / \"job_1\"\n+        output_dir.mkdir(parents=True)\n+        run_md = output_dir / \"run.md\"\n+        run_md.write_text(\"x\")\n+\n+        # Old enough to be deleted (>14 days)\n+        from datetime import datetime, timezone, timedelta\n+        old_ts = (datetime.now(timezone.utc) - timedelta(days=20)).isoformat()\n+\n+        tracked_file = _isolate_env / \"disk-cleanup\" / \"tracked.json\"\n+        tracked_file.parent.mkdir(parents=True, exist_ok=True)\n+        tracked_file.write_text(json.dumps([{\n+            \"path\": str(run_md),\n+            \"category\": \"cron-output\",\n+            \"timestamp\": old_ts,\n+            \"size\": 10,\n+        }]))\n+\n+        summary = dg.quick()\n+        assert summary[\"deleted\"] == 1, \"valid old cron-output should be deleted\"\n+        assert not run_md.exists()\n+\n+\n class TestTrackForgetQuick:\n     def test_track_then_quick_deletes_test(self, _isolate_env):\n         dg = _load_lib()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}