{"repo":"NousResearch/hermes-agent","public_id":"34600","display_label":"Cron job loss during updates and migrations","summary":"These issues and pull requests concern scheduled cron jobs being silently deleted or cleared during Hermes updates, config migrations, and related backup/restore operations. The work spans bug reports, feature durability questions, and several proposed fixes targeting atomic file handling and payload exclusions.","subsystem":"cron","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34840,"merged_at":"2026-05-29T20:22:54+00:00"},"resolution":{"state":"resolved","by":"pr-34840"},"freshness":{"state":"verified","last_verified_at":"2026-05-29T20:22:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":32164,"pr_number":34840,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-29T20:22:54+00:00"},{"issue_number":34600,"pr_number":34840,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-29T20:22:54+00:00"}],"members":[{"kind":"issue","number":34600,"title":"fix(cron): config migration (23 to 24) may silently clear cron/jobs.json","url":null,"state":"closed"},{"kind":"pr","number":34602,"title":"fix(cron): restore jobs.json emptied by config migration on update","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3845d86b9","number":0},{"kind":"pr","value":"#34840","number":34840},{"kind":"pr","value":"#33834","number":33834}],"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"},"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     \"252620095+briandevans@users.noreply.github.com\": \"briandevans\",\n     \"[redacted:email]\": \"Bartok9\",\n+    \"96944678+ymylive@users.noreply.github.com\": \"sweetcornna\",\n     \"[redacted:email]\": \"CruxExperts\",\n     \"154585401+LeonSGP43@users.noreply.github.com\": \"LeonSGP43\",\n     \"12250313+Kailigithub@users.noreply.github.com\": \"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":35594,"title":"fix(backup): atomically restore cron/jobs.json via tempfile + rename","url":null,"state":"open","close_reason":{"category":"partial_fix","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}