{"repo":"NousResearch/hermes-agent","public_id":"7818","display_label":"Gateway cron agent restart loop prevention fixes","summary":"Multiple fixes addressing scenarios where agents could schedule or trigger gateway restart/kill commands that cause infinite respawn loops, including guards in cron dispatch, terminal blocking, loop detection, and one-shot job commit ordering.","subsystem":"cron","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":3,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35679},"resolution":{"state":"partially_resolved","by":"pr-35679"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T09:48:36+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.20444444444444446},"fix_edges":[{"issue_number":30719,"pr_number":35679,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":30719,"pr_number":56177,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":30719,"pr_number":56240,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":38758,"pr_number":56177,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":7818,"title":"Two architectural gaps exposed by a launchctl self-kill loop: smart approval auto-approves self-termination, and resume doesn't detect post-crash retry","url":null,"state":"closed"},{"kind":"issue","number":30719,"title":"Agent can schedule gateway-restart cron job that kills its own runtime, creating respawn loop with launchctl/systemd KeepAlive","url":null,"state":"closed"},{"kind":"pr","number":30728,"title":"fix(gateway,cron): prevent agent restart loops via self-targeting gateway commands (#30719)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35679","number":35679},{"kind":"pr","value":"#30719","number":30719}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":33395,"title":"fix(gateway,cron): prevent agent restart loops — salvage #30728 + correctness follow-ups (#30719)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56240","number":56240},{"kind":"pr","value":"#56240","number":56240},{"kind":"pr","value":"#33395","number":33395},{"kind":"pr","value":"#30719","number":30719}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35679,"title":"fix(gateway,cron): prevent agent gateway-restart loops (salvage #30728)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/cron.py","hermes_cli/gateway.py","tests/conftest.py","tests/hermes_cli/test_gateway_restart_loop.py"],"fix_hunks":[{"file":"hermes_cli/cron.py","hunks":["@@ -6,6 +6,7 @@\n \"\"\"\n \n import json\n+import re\n import sys\n from pathlib import Path\n from typing import Iterable, List, Optional","@@ -15,6 +16,24 @@\n \n from hermes_cli.colors import Colors, color\n \n+# Patterns that indicate a cron job targets the gateway lifecycle.\n+# Matches commands that restart/stop the gateway or its service manager.\n+# Deliberately specific — a bare \"gateway ... restart\" catch-all would block\n+# legitimate prompts that merely mention an unrelated gateway (e.g. \"summarize\n+# the API gateway logs and report restart events\").\n+_GATEWAY_LIFECYCLE_PATTERNS = re.compile(\n+    r\"(?i)\"\n+    r\"(hermes\\s+gateway\\s+(restart|stop|start))\"\n+    r\"|(launchctl\\s+(kickstart|unload|load|stop|restart)\\s+.*hermes)\"\n+    r\"|(systemctl\\s+(restart|stop|start)\\s+.*hermes)\"\n+    r\"|(p?kill\\s+.*hermes.*gateway)\"\n+)\n+\n+\n+def _contains_gateway_lifecycle_command(text: str) -> bool:\n+    \"\"\"Return True if *text* contains a gateway lifecycle command pattern.\"\"\"\n+    return bool(_GATEWAY_LIFECYCLE_PATTERNS.search(text))\n+\n \n def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:\n     if skills is None:","@@ -166,6 +185,28 @@ def cron_status():\n \n \n def cron_create(args):\n+    # Defense: reject cron jobs that contain gateway lifecycle commands.\n+    # Prevents agents from scheduling their own restart/stop, which creates\n+    # SIGTERM-respawn loops under launchd/systemd KeepAlive (#30719).\n+    prompt = getattr(args, \"prompt\", None) or \"\"\n+    script = getattr(args, \"script\", None)\n+    combined = prompt\n+    if script:\n+        try:\n+            script_text = Path(script).read_text(encoding=\"utf-8\")\n+            combined = f\"{combined}\\n{script_text}\"\n+        except (OSError, UnicodeDecodeError):\n+            pass\n+    if _contains_gateway_lifecycle_command(combined):\n+        print(color(\n+            \"Blocked: cron job contains a gateway lifecycle command \"\n+            \"(restart/stop/kill).\\n\"\n+            \"This is blocked to prevent restart loops (#30719).\\n\"\n+            \"Use `hermes gateway restart` from a shell outside the gateway.\",\n+            Colors.RED,\n+        ))\n+        return 1\n+\n     result = _cron_api(\n         action=\"create\",\n         schedule=args.schedule,"]},{"file":"hermes_cli/gateway.py","hunks":["@@ -5422,6 +5422,16 @@ def _gateway_command_inner(args):\n             sys.exit(1)\n \n     elif subcmd == \"stop\":\n+        # Defense: refuse self-targeting gateway stop from inside the gateway.\n+        # Prevents agent-initiated kill loops when combined with supervisor KeepAlive.\n+        if os.getenv(\"_HERMES_GATEWAY\") == \"1\":\n+            print_error(\n+                \"Refusing to stop the gateway from inside the gateway process.\\n\"\n+                \"This command was blocked to prevent restart loops.\\n\"\n+                \"Use `hermes gateway stop` from a shell outside the running gateway.\"\n+            )\n+            sys.exit(1)\n+\n         stop_all = getattr(args, 'all', False)\n         system = getattr(args, 'system', False)\n ","@@ -5497,6 +5507,16 @@ def _gateway_command_inner(args):\n                 print(f\"✓ Stopped {get_service_name()} service\")\n     \n     elif subcmd == \"restart\":\n+        # Defense: refuse self-targeting gateway restart from inside the gateway.\n+        # Prevents agent-initiated kill loops when combined with supervisor KeepAlive.\n+        if os.getenv(\"_HERMES_GATEWAY\") == \"1\":\n+            print_error(\n+                \"Refusing to restart the gateway from inside the gateway process.\\n\"\n+                \"This command was blocked to prevent restart loops.\\n\"\n+                \"Use `hermes gateway restart` from a shell outside the running gateway.\"\n+            )\n+            sys.exit(1)\n+\n         # Try service first, fall back to killing and restarting\n         service_available = False\n         system = getattr(args, 'system', False)"]},{"file":"tests/conftest.py","hunks":["@@ -182,6 +182,7 @@ def _looks_like_credential(name: str) -> bool:\n     \"HERMES_SESSION_SOURCE\",\n     \"HERMES_SESSION_KEY\",\n     \"HERMES_GATEWAY_SESSION\",\n+    \"_HERMES_GATEWAY\",\n     \"HERMES_PLATFORM\",\n     \"HERMES_MODEL\",\n     \"HERMES_INFERENCE_MODEL\","]},{"file":"tests/hermes_cli/test_gateway_restart_loop.py","hunks":["@@ -0,0 +1,252 @@\n+\"\"\"Tests for gateway restart-loop defenses (#30719).\n+\n+Covers:\n+- Defense 1: gateway stop/restart refuse when _HERMES_GATEWAY=1\n+- Defense 2: cron create rejects prompts containing gateway lifecycle commands\n+- _contains_gateway_lifecycle_command pattern matching\n+\"\"\"\n+\n+import os\n+from argparse import Namespace\n+\n+import pytest\n+\n+from hermes_cli.cron import (\n+    _contains_gateway_lifecycle_command,\n+    cron_command,\n+)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Defense 2: _contains_gateway_lifecycle_command pattern tests\n+# ---------------------------------------------------------------------------\n+\n+class TestGatewayLifecyclePattern:\n+    \"\"\"Verify the regex catches gateway lifecycle commands.\"\"\"\n+\n+    @pytest.mark.parametrize(\"text\", [\n+        \"hermes gateway restart\",\n+        \"hermes gateway stop\",\n+        \"hermes gateway start\",\n+        \"hermes  gateway  restart\",         # double spaces\n+        \"Hermez Gateway Restart\".lower().replace(\"z\", \"s\"),  # case handled\n+        \"HERMES GATEWAY RESTART\",           # uppercase\n+    ])\n+    def test_hermes_gateway_commands(self, text):\n+        assert _contains_gateway_lifecycle_command(text), f\"Should match: {text!r}\"\n+\n+    @pytest.mark.parametrize(\"text\", [\n+        \"launchctl kickstart gui/501/ai.hermes.gateway\",\n+        \"launchctl unload ~/Library/LaunchAgents/ai.hermes.gateway.plist\",\n+        \"launchctl stop ai.hermes.gateway\",\n+        \"systemctl restart hermes-gateway\",\n+        \"systemctl stop hermes-gateway.service\",\n+        \"systemctl start hermes-gateway\",\n+    ])\n+    def test_service_manager_commands(self, text):\n+        assert _contains_gateway_lifecycle_command(text), f\"Should match: {text!r}\"\n+\n+    @pytest.mark.parametrize(\"text\", [\n+        \"kill hermes gateway process\",\n+        \"pkill -f hermes.*gateway\",\n+    ])\n+    def test_kill_commands(self, text):\n+        assert _contains_gateway_lifecycle_command(text), f\"Should match: {text!r}\"\n+\n+    @pytest.mark.parametrize(\"text\", [\n+        \"restart the server application\",\n+        \"hermes cron list\",\n+        \"hermes update\",\n+        \"hermes config set model claude\",\n+        \"echo 'just a normal cron job'\",\n+        \"run the backup script\",\n+        \"gateway is running fine\",\n+        # Regression (#30728 follow-up): legit prompts that merely mention an\n+        # unrelated gateway + a restart must NOT be blocked.\n+        \"Summarize the API gateway logs and report any restart events from last night\",\n+        \"Check if the payment gateway needs a restart after the deploy\",\n+        \"Monitor the gateway and tell me if a restart is recommended\",\n+    ])\n+    def test_safe_commands(self, text):\n+        assert not _contains_gateway_lifecycle_command(text), f\"Should NOT match: {text!r}\"\n+\n+\n+class TestCronCreateLifecycleBlock:\n+    \"\"\"Verify cron create rejects gateway lifecycle prompts.\"\"\"\n+\n+    @pytest.fixture(autouse=True)\n+    def _setup_cron_dir(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(\"cron.jobs.CRON_DIR\", tmp_path / \"cron\")\n+        monkeypatch.setattr(\"cron.jobs.JOBS_FILE\", tmp_path / \"cron\" / \"jobs.json\")\n+        monkeypatch.setattr(\"cron.jobs.OUTPUT_DIR\", tmp_path / \"cron\" / \"output\")\n+\n+    def test_block_hermes_gateway_restart(self, capsys):\n+        args = Namespace(\n+            cron_command=\"create\",\n+            schedule=\"30m\",\n+            prompt=\"Upgrade hermes then run hermes gateway restart\",\n+            name=None,\n+            deliver=None,\n+            repeat=None,\n+            skill=None,\n+            skills=None,\n+            script=None,\n+            workdir=None,\n+            profile=None,\n+            no_agent=False,\n+        )\n+        rc = cron_command(args)\n+        assert rc == 1\n+        out = capsys.readouterr().out\n+        assert \"Blocked\" in out\n+        assert \"#30719\" in out\n+\n+    def test_block_launchctl_kickstart(self, capsys):\n+        args = Namespace(\n+            cron_command=\"create\",\n+            schedule=\"0 9 * * *\",\n+            prompt=\"Run launchctl kickstart -k gui/501/ai.hermes.gateway\",\n+            name=None,\n+            deliver=None,\n+            repeat=None,\n+            skill=None,\n+            skills=None,\n+            script=None,\n+            workdir=None,\n+            profile=None,\n+            no_agent=False,\n+        )\n+        rc = cron_command(args)\n+        assert rc == 1\n+        out = capsys.readouterr().out\n+        assert \"Blocked\" in out\n+\n+    def test_block_script_with_lifecycle_command(self, tmp_path, capsys):\n+        script = tmp_path / \"restart.sh\"\n+        script.write_text(\"#!/bin/bash\\nhermes gateway restart\\n\")\n+        args = Namespace(\n+            cron_command=\"create\",\n+            schedule=\"1h\",\n+            prompt=None,\n+            name=None,\n+            deliver=None,\n+            repeat=None,\n+            skill=None,\n+            skills=None,\n+            script=str(script),\n+            workdir=None,\n+            profile=None,\n+            no_agent=False,\n+        )\n+        rc = cron_command(args)\n+        assert rc == 1\n+        out = capsys.readouterr().out\n+        assert \"Blocked\" in out\n+\n+    def test_allow_safe_prompt(self, capsys):\n+        args = Namespace(\n+            cron_command=\"create\",\n+            schedule=\"30m\",\n+            prompt=\"Check server health and report status\",\n+            name=None,\n+            deliver=None,\n+            repeat=None,\n+            skill=None,\n+            skills=None,\n+            script=None,\n+            workdir=None,\n+            profile=None,\n+            no_agent=False,\n+        )\n+        rc = cron_command(args)\n+        assert rc == 0\n+        out = capsys.readouterr().out\n+        assert \"Created job\" in out\n+\n+    def test_allow_empty_prompt(self, capsys):\n+        \"\"\"Empty prompt (no lifecycle content) should pass the filter — the\n+        API will still reject it for lacking prompt+skill, but that's a\n+        separate validation, not the lifecycle guard.\"\"\"\n+        args = Namespace(\n+            cron_command=\"create\",\n+            schedule=\"30m\",\n+            prompt=None,\n+            name=None,\n+            deliver=None,\n+            repeat=None,\n+            skill=None,\n+            skills=None,\n+            script=None,\n+            workdir=None,\n+            profile=None,\n+            no_agent=False,\n+        )\n+        rc = cron_command(args)\n+        # The lifecycle guard passes (no gateway command in prompt).\n+        # The API rejects it for \"requires prompt or skill\" → rc 1, but\n+        # the error message is about prompt/skill, NOT about \"Blocked\".\n+        out = capsys.readouterr().out\n+        assert \"Blocked\" not in out\n+\n+\n+# ---------------------------------------------------------------------------\n+# Defense 1: gateway stop/restart refuse inside gateway\n+# ---------------------------------------------------------------------------\n+\n+class TestGatewaySelfTargetingGuard:\n+    \"\"\"Verify hermes gateway stop/restart refuse when _HERMES_GATEWAY=1.\"\"\"\n+\n+    def test_stop_refuses_inside_gateway(self, monkeypatch):\n+        monkeypatch.setenv(\"_HERMES_GATEWAY\", \"1\")\n+        from hermes_cli.gateway import gateway_command\n+        args = Namespace(gateway_command=\"stop\", all=False, system=False)\n+        with pytest.raises(SystemExit) as exc_info:\n+            gateway_command(args)\n+        assert exc_info.value.code == 1\n+\n+    def test_restart_refuses_inside_gateway(self, monkeypatch):\n+        monkeypatch.setenv(\"_HERMES_GATEWAY\", \"1\")\n+        from hermes_cli.gateway import gateway_command\n+        args = Namespace(gateway_command=\"restart\", all=False, system=False)\n+        with pytest.raises(SystemExit) as exc_info:\n+            gateway_command(args)\n+        assert exc_info.value.code == 1\n+\n+    def test_stop_allows_outside_gateway(self, monkeypatch):\n+        # With the gateway marker unset, the self-targeting guard must NOT\n+        # fire. Prove control reaches the real stop path (rather than driving\n+        # real signal delivery, which would trip the live-system guard) by\n+        # short-circuiting the first downstream call with a sentinel.\n+        monkeypatch.delenv(\"_HERMES_GATEWAY\", raising=False)\n+        import hermes_cli.gateway as gw\n+\n+        class _Reached(Exception):\n+            pass\n+\n+        def _sentinel(*a, **k):\n+            raise _Reached()\n+\n+        monkeypatch.setattr(gw, \"_dispatch_via_service_manager_if_s6\", _sentinel)\n+        monkeypatch.setattr(gw, \"_dispatch_all_via_service_manager_if_s6\", _sentinel)\n+        args = Namespace(gateway_command=\"stop\", all=False, system=False)\n+        with pytest.raises(_Reached):\n+            gw.gateway_command(args)\n+\n+    def test_restart_allows_outside_gateway(self, monkeypatch):\n+        # Same as above for restart: guard must not fire when the marker is\n+        # unset. The first thing restart does after the guard is the s6\n+        # dispatch check — sentinel it so we never reach real signal delivery.\n+        monkeypatch.delenv(\"_HERMES_GATEWAY\", raising=False)\n+        import hermes_cli.gateway as gw\n+\n+        class _Reached(Exception):\n+            pass\n+\n+        def _sentinel(*a, **k):\n+            raise _Reached()\n+\n+        monkeypatch.setattr(gw, \"_dispatch_via_service_manager_if_s6\", _sentinel)\n+        monkeypatch.setattr(gw, \"_dispatch_all_via_service_manager_if_s6\", _sentinel)\n+        args = Namespace(gateway_command=\"restart\", all=False, system=False)\n+        with pytest.raises(_Reached):\n+            gw.gateway_command(args)\n"]}]},{"kind":"pr","number":35815,"title":"fix(gateway): replace blanket internal-restart guard with loop detector","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35668","number":35668}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":56177,"title":"fix(cron): commit one-shot dispatch before side effect to stop crash re-fire loop (#38758)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/jobs.py","cron/scheduler.py","tests/cron/test_jobs.py","tests/cron/test_scheduler.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -1249,13 +1249,27 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,\n                 # be claimed again on its next fire (Phase 4C CAS).\n                 job[\"fire_claim\"] = None\n                 \n-                # Increment completed count\n+                # Increment completed count.  Finite one-shot jobs are\n+                # pre-claimed by claim_dispatch() BEFORE the side effect runs\n+                # (issue #38758), which already incremented completed — do not\n+                # double-count them here.  Recurring jobs and direct callers\n+                # with no pre-run claim still get the legacy increment.\n                 if job.get(\"repeat\"):\n-                    job[\"repeat\"][\"completed\"] = job[\"repeat\"].get(\"completed\", 0) + 1\n-                    \n+                    repeat = job[\"repeat\"]\n+                    times = repeat.get(\"times\")\n+                    completed = repeat.get(\"completed\", 0)\n+                    kind = job.get(\"schedule\", {}).get(\"kind\")\n+                    preclaimed_oneshot = (\n+                        kind == \"once\"\n+                        and times is not None\n+                        and times > 0\n+                        and completed > 0\n+                    )\n+                    if not preclaimed_oneshot:\n+                        completed += 1\n+                        repeat[\"completed\"] = completed\n+\n                     # Check if we've hit the repeat limit\n-                    times = job[\"repeat\"].get(\"times\")\n-                    completed = job[\"repeat\"][\"completed\"]\n                     if times is not None and times > 0 and completed >= times:\n                         # Remove the job (limit reached)\n                         jobs.pop(i)","@@ -1300,6 +1314,69 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,\n         logger.warning(\"mark_job_run: job_id %s not found, skipping save\", job_id)\n \n \n+def claim_dispatch(job_id: str) -> bool:\n+    \"\"\"Atomically claim a finite one-shot job dispatch BEFORE execution.\n+\n+    Increments ``repeat.completed`` under the cross-process jobs lock and\n+    persists the claim immediately, so that if the tick dies mid-execution\n+    (gateway kill, OOM, segfault, hard-timeout) the dispatch is not lost.\n+    This converts finite one-shot jobs from *at-least-once* to *at-most-times*\n+    semantics — a job that self-destructs fires at most ``repeat.times`` times\n+    instead of infinitely (issue #38758).\n+\n+    Returns ``True`` if the caller may proceed to run the job, ``False`` if the\n+    dispatch limit is already reached (in which case the stale job is removed).\n+\n+    Only claims jobs with ``schedule.kind == \"once\"`` and ``repeat.times > 0``.\n+    Recurring jobs (they use ``advance_next_run``) and infinite-repeat / no-repeat\n+    jobs are left unchanged and always allowed to proceed.\n+    \"\"\"\n+    with _jobs_lock():\n+        jobs = load_jobs()\n+        for i, job in enumerate(jobs):\n+            if job[\"id\"] != job_id:\n+                continue\n+            if job.get(\"schedule\", {}).get(\"kind\") != \"once\":\n+                return True  # recurring jobs use advance_next_run(), not dispatch claims\n+            repeat = job.get(\"repeat\")\n+            if not repeat:\n+                return True  # no repeat limit — always dispatch\n+            times = repeat.get(\"times\")\n+            if times is None or times <= 0:\n+                return True  # infinite — always dispatch\n+            completed = repeat.get(\"completed\", 0)\n+            if completed >= times:\n+                # Already dispatched the max number of times (e.g. a prior\n+                # tick claimed then died before mark_job_run could remove it).\n+                # Clean up so it stops appearing as due on every tick.\n+                jobs.pop(i)\n+                save_jobs(jobs)\n+                logger.info(\n+                    \"Job '%s': dispatch limit reached (%d/%d) — removing\",\n+                    job.get(\"name\", job[\"id\"]),\n+                    completed,\n+                    times,\n+                )\n+                return False\n+            # Claim this dispatch before the side effect runs.\n+            repeat[\"completed\"] = completed + 1\n+            save_jobs(jobs)\n+            logger.debug(\n+                \"Job '%s': claimed dispatch %d/%d\",\n+                job.get(\"name\", job[\"id\"]),\n+                repeat[\"completed\"],\n+                times,\n+            )\n+            return True\n+\n+        logger.debug(\n+            \"claim_dispatch: job_id %s not in store — proceeding without claim \"\n+            \"(handed-in job dict; nothing to persist a claim against)\",\n+            job_id,\n+        )\n+        return True\n+\n+\n def advance_next_run(job_id: str) -> bool:\n     \"\"\"Preemptively advance next_run_at for a recurring job before execution.\n ","@@ -1543,6 +1620,31 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]:\n                             break\n                     # Fall through to due.append(job) — execute once now\n \n+            # One-shot dispatch-limit guard (issue #38758): a finite one-shot\n+            # claimed via claim_dispatch() but whose tick died before\n+            # mark_job_run could remove it will have completed >= times while\n+            # still looking due (last_run_at was never written, so the\n+            # recovery helper re-armed it). Remove it instead of re-firing.\n+            if kind == \"once\":\n+                repeat = job.get(\"repeat\")\n+                if repeat:\n+                    times = repeat.get(\"times\")\n+                    completed = repeat.get(\"completed\", 0)\n+                    if times is not None and times > 0 and completed >= times:\n+                        logger.info(\n+                            \"Job '%s': one-shot dispatch limit reached (%d/%d) \"\n+                            \"— removing stale due entry\",\n+                            job.get(\"name\", job[\"id\"]),\n+                            completed,\n+                            times,\n+                        )\n+                        for rj in raw_jobs:\n+                            if rj[\"id\"] == job[\"id\"]:\n+                                raw_jobs.remove(rj)\n+                                needs_save = True\n+                                break\n+                        continue\n+\n             due.append(job)\n \n     if needs_save:"]},{"file":"cron/scheduler.py","hunks":["@@ -236,7 +236,7 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:\n     \"QQBOT_HOME_CHANNEL\": \"QQ_HOME_CHANNEL\",\n }\n \n-from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run\n+from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run, claim_dispatch\n \n # Sentinel: when a cron agent has nothing new to report, it can start its\n # response with this marker to suppress delivery.  Output is still saved","@@ -2781,6 +2781,20 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) -\n     failure is recorded via ``mark_job_run``), False only if processing raised.\n     \"\"\"\n     try:\n+        # Pre-run dispatch claim (issue #38758): atomically commit a finite\n+        # one-shot's dispatch BEFORE its side effect runs, so a tick that dies\n+        # mid-execution (gateway kill, OOM, segfault, hard-timeout) cannot\n+        # re-fire the job forever on restart. No-op for recurring jobs (they\n+        # use advance_next_run) and infinite/no-repeat jobs. This lives here in\n+        # the shared body so BOTH the built-in ticker and the external provider\n+        # (Chronos fire_due) get at-most-times semantics.\n+        if not claim_dispatch(job[\"id\"]):\n+            logger.info(\n+                \"Job '%s': one-shot dispatch limit reached — skipping\",\n+                job.get(\"name\", job[\"id\"]),\n+            )\n+            return True  # not an error — already handled/removed\n+\n         success, output, final_response, error = run_job(job)\n \n         output_file = save_job_output(job[\"id\"], output)"]},{"file":"tests/cron/test_jobs.py","hunks":["@@ -19,6 +19,7 @@\n     remove_job,\n     mark_job_run,\n     advance_next_run,\n+    claim_dispatch,\n     get_due_jobs,\n     save_job_output,\n )","@@ -1314,3 +1315,102 @@ def test_cron_output_keep_defaults_on_bad_config(self, monkeypatch):\n             \"hermes_cli.config.load_config\", lambda: {\"cron\": {\"output_retention\": \"oops\"}}\n         )\n         assert jobs._cron_output_keep() == jobs._CRON_OUTPUT_DEFAULT_KEEP\n+\n+\n+# =========================================================================\n+# claim_dispatch — pre-run one-shot crash safety (issue #38758)\n+# =========================================================================\n+\n+class TestClaimDispatch:\n+    \"\"\"One-shot jobs must commit their dispatch BEFORE the side effect runs, so\n+    a tick that dies mid-execution (gateway kill, OOM, hard-timeout) can re-fire\n+    the job at most ``repeat.times`` times instead of infinitely.\"\"\"\n+\n+    def _oneshot(self, times=1, completed=0):\n+        return {\n+            \"id\": \"os1\",\n+            \"name\": \"one-shot\",\n+            \"enabled\": True,\n+            \"schedule\": {\"kind\": \"once\", \"run_at\": \"2026-01-01T00:00:00+00:00\"},\n+            \"repeat\": {\"times\": times, \"completed\": completed},\n+        }\n+\n+    def test_claim_increments_and_persists(self, tmp_cron_dir):\n+        save_jobs([self._oneshot(times=1, completed=0)])\n+        assert claim_dispatch(\"os1\") is True\n+        # Persisted BEFORE any side effect — survives a crash.\n+        assert load_jobs()[0][\"repeat\"][\"completed\"] == 1\n+\n+    def test_already_dispatched_oneshot_is_removed(self, tmp_cron_dir):\n+        # A prior tick claimed (completed==times) then died before mark_job_run\n+        # could remove the job.  The next claim must refuse AND clean up.\n+        save_jobs([self._oneshot(times=1, completed=1)])\n+        assert claim_dispatch(\"os1\") is False\n+        assert load_jobs() == []  # removed, will not re-fire\n+\n+    def test_recurring_job_is_not_claimed(self, tmp_cron_dir):\n+        job = {\n+            \"id\": \"rec\",\n+            \"schedule\": {\"kind\": \"interval\", \"minutes\": 5},\n+            \"repeat\": {\"times\": 3, \"completed\": 0},\n+        }\n+        save_jobs([job])\n+        assert claim_dispatch(\"rec\") is True\n+        # Recurring jobs use advance_next_run(); claim must NOT touch completed.\n+        assert load_jobs()[0][\"repeat\"][\"completed\"] == 0\n+\n+    def test_infinite_oneshot_not_claimed(self, tmp_cron_dir):\n+        job = self._oneshot(times=0, completed=0)  # times<=0 means infinite\n+        save_jobs([job])\n+        assert claim_dispatch(\"os1\") is True\n+        assert load_jobs()[0][\"repeat\"][\"completed\"] == 0\n+\n+    def test_no_repeat_block_not_claimed(self, tmp_cron_dir):\n+        job = {\"id\": \"os1\", \"schedule\": {\"kind\": \"once\", \"run_at\": \"2026-01-01T00:00:00+00:00\"}}\n+        save_jobs([job])\n+        assert claim_dispatch(\"os1\") is True\n+        assert \"repeat\" not in load_jobs()[0]\n+\n+    def test_missing_job_proceeds(self, tmp_cron_dir):\n+        # A handed-in job dict not persisted in the store (external provider /\n+        # direct caller) can't be claimed — proceed rather than suppress it.\n+        save_jobs([])\n+        assert claim_dispatch(\"ghost\") is True\n+\n+    def test_mark_job_run_does_not_double_count_preclaimed_oneshot(self, tmp_cron_dir):\n+        # Full lifecycle: claim bumps completed to times, then mark_job_run must\n+        # NOT increment again — it recognizes the pre-claim and removes the job.\n+        save_jobs([self._oneshot(times=1, completed=0)])\n+        assert claim_dispatch(\"os1\") is True\n+        assert load_jobs()[0][\"repeat\"][\"completed\"] == 1\n+        mark_job_run(\"os1\", success=True)\n+        assert load_jobs() == []  # completed once, removed — not fired twice\n+\n+    def test_mark_job_run_still_increments_recurring(self, tmp_cron_dir):\n+        # The double-count guard is one-shot-specific; recurring jobs keep the\n+        # legacy post-run increment.\n+        job = {\n+            \"id\": \"rec\",\n+            \"schedule\": {\"kind\": \"interval\", \"minutes\": 5},\n+            \"repeat\": {\"times\": 3, \"completed\": 1},\n+        }\n+        save_jobs([job])\n+        mark_job_run(\"rec\", success=True)\n+        assert load_jobs()[0][\"repeat\"][\"completed\"] == 2\n+\n+    def test_get_due_jobs_removes_stale_maxed_oneshot(self, tmp_cron_dir):\n+        # A claimed one-shot whose tick died leaves completed>=times with\n+        # last_run_at still unset, so the recovery helper re-arms it as due.\n+        # get_due_jobs must drop it instead of returning it for another fire.\n+        past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()\n+        save_jobs([{\n+            \"id\": \"os1\",\n+            \"name\": \"one-shot\",\n+            \"enabled\": True,\n+            \"schedule\": {\"kind\": \"once\", \"run_at\": past},\n+            \"repeat\": {\"times\": 1, \"completed\": 1},\n+            \"next_run_at\": None,\n+        }])\n+        due = get_due_jobs()\n+        assert due == []\n+        assert load_jobs() == []  # cleaned up"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -2542,6 +2542,46 @@ def test_whitespace_only_response_is_marked_failed_not_delivered(self):\n         )\n \n \n+class TestOneShotDispatchClaim:\n+    \"\"\"run_one_job must claim a finite one-shot's dispatch BEFORE run_job so a\n+    tick that dies mid-execution can't re-fire it forever (issue #38758).\"\"\"\n+\n+    def _oneshot(self):\n+        return {\n+            \"id\": \"monitor-job\",\n+            \"name\": \"monitor\",\n+            \"deliver\": \"origin\",\n+            \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"123\"},\n+            \"schedule\": {\"kind\": \"once\", \"run_at\": \"2026-01-01T00:00:00+00:00\"},\n+            \"repeat\": {\"times\": 1, \"completed\": 0},\n+        }\n+\n+    def test_claim_runs_before_run_job(self):\n+        order = []\n+        with patch(\"cron.scheduler.get_due_jobs\", return_value=[self._oneshot()]), \\\n+             patch(\"cron.scheduler.claim_dispatch\", side_effect=lambda _id: order.append(\"claim\") or True), \\\n+             patch(\"cron.scheduler.run_job\", side_effect=lambda _j: order.append(\"run\") or (True, \"# out\", \"ok\", None)), \\\n+             patch(\"cron.scheduler.save_job_output\", return_value=\"/tmp/out.md\"), \\\n+             patch(\"cron.scheduler._deliver_result\"), \\\n+             patch(\"cron.scheduler.mark_job_run\"):\n+            from cron.scheduler import tick\n+            tick(verbose=False)\n+        assert order == [\"claim\", \"run\"]  # claim strictly before side effect\n+\n+    def test_refused_claim_skips_run_job(self):\n+        with patch(\"cron.scheduler.get_due_jobs\", return_value=[self._oneshot()]), \\\n+             patch(\"cron.scheduler.claim_dispatch\", return_value=False), \\\n+             patch(\"cron.scheduler.run_job\") as run_mock, \\\n+             patch(\"cron.scheduler.save_job_output\"), \\\n+             patch(\"cron.scheduler._deliver_result\") as deliver_mock, \\\n+             patch(\"cron.scheduler.mark_job_run\") as mark_mock:\n+            from cron.scheduler import tick\n+            tick(verbose=False)\n+        run_mock.assert_not_called()\n+        deliver_mock.assert_not_called()\n+        mark_mock.assert_not_called()\n+\n+\n class TestBuildJobPromptSilentHint:\n     \"\"\"Verify _build_job_prompt always injects [SILENT] guidance.\"\"\"\n \n"]}]},{"kind":"pr","number":56240,"title":"fix(gateway,cron): guard cron model-tool path + auto-resume loop breaker (#30719)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/jobs.py","cron/lifecycle_guard.py","gateway/restart_loop_guard.py","gateway/run.py","hermes_cli/config.py","hermes_cli/cron.py","tests/hermes_cli/test_gateway_restart_loop.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -960,6 +960,15 @@ def create_job(\n         context_from = None\n \n     prompt_text = _coerce_job_text(prompt)\n+\n+    # Reject cron jobs that schedule gateway-lifecycle commands. Prevents\n+    # agent-driven SIGTERM-respawn loops under launchd/systemd KeepAlive\n+    # (#30719). Enforced here (not only in the CLI layer) so the agent's\n+    # `cronjob` model tool — which calls create_job directly — is also\n+    # covered, not just `hermes cron create`.\n+    from cron.lifecycle_guard import check_gateway_lifecycle\n+    check_gateway_lifecycle(prompt_text, normalized_script)\n+\n     label_source = (prompt_text or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or \"cron job\"\n \n     provider_snapshot, model_snapshot = _compute_provider_model_snapshots("]},{"file":"cron/lifecycle_guard.py","hunks":["@@ -0,0 +1,141 @@\n+\"\"\"Gateway lifecycle guard for cron job creation (#30719).\n+\n+An agent running inside a gateway can schedule a cron job that calls\n+``hermes gateway restart`` (or ``launchctl kickstart ai.hermes.gateway``\n+or ``systemctl restart hermes-gateway``).  When the cron fires, the\n+gateway dies, the supervisor (launchd KeepAlive / systemd Restart=)\n+revives it, auto-resume picks up the offending session, and the resumed\n+turn re-runs the same logic — a SIGTERM-respawn loop every ~10 seconds\n+until manually broken.\n+\n+This module rejects cron job specs whose prompt or script contains a\n+direct shell-level gateway-lifecycle command.  It is enforced at\n+``cron.jobs.create_job`` so it fires on every job-creation path: the\n+``hermes cron create`` CLI subcommand AND the agent's ``cronjob`` model\n+tool (which calls ``create_job`` directly, bypassing the CLI layer).\n+\n+The pattern is intentionally command-shaped: it anchors on a concrete\n+command identifier (``hermes gateway``, ``launchctl ... hermes-gateway``,\n+``systemctl ... hermes-gateway``, ``pkill`` against the gateway) so it\n+cannot fire on prose.  A cron ``prompt`` is fed to a future LLM, not a\n+shell, so an over-broad substring match on English (\"Kong API gateway\n+autoscaling and restart behavior\") would produce a high false-positive\n+rate without preventing the actual foot-gun, which requires a real\n+command shape.\n+\n+This is a defence-in-depth layer.  ``tools/terminal_tool.py`` already\n+blocks these commands at *execution* time when ``_HERMES_GATEWAY=1``, and\n+``hermes gateway stop|restart`` refuse to self-target from inside the\n+gateway.  Blocking at *creation* time as well means the agent gets an\n+immediate, informative rejection instead of scheduling a job that will\n+only fail (silently) when it fires.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import re\n+from pathlib import Path\n+from typing import Optional\n+\n+\n+class GatewayLifecycleBlocked(ValueError):\n+    \"\"\"Raised when a cron job spec contains a gateway-lifecycle command.\"\"\"\n+\n+\n+# Shell-level command shapes that target the gateway lifecycle. Each branch\n+# is anchored on a concrete command identifier so a match can only fire on\n+# actual shell-command-shaped strings, not on prose.\n+_GATEWAY_LIFECYCLE_PATTERN = re.compile(\n+    r\"(?i)\"\n+    # Branch A: `hermes gateway restart|stop` — the canonical foot-gun.\n+    # `start` is intentionally excluded: starting a gateway from inside a\n+    # gateway is benign (a no-op or \"already running\" error), and a\n+    # legitimate cron job might start a sibling profile's gateway.\n+    r\"(?:hermes\\s+gateway\\s+(?:restart|stop))\"\n+    # Branch B: launchctl ops on a hermes-gateway label. macOS launchd\n+    # labels look like `ai.hermes.gateway` / `hermes-gateway`. Requiring the\n+    # gateway identifier prevents blocking unrelated hermes services (e.g.\n+    # `launchctl unload ai.hermes.update-checker.plist`).\n+    r\"|(?:launchctl\\s+(?:kickstart|unload|load|stop|restart)\\b[^\\n]*\\bhermes[.\\-]?gateway)\"\n+    # Branch C: systemctl ops on a hermes-gateway unit.\n+    r\"|(?:systemctl\\s+(?:-\\S+\\s+)*(?:restart|stop|start)\\b[^\\n]*\\bhermes[.\\-]?gateway)\"\n+    # Branch D: pkill / kill targeting the hermes gateway process. Both\n+    # token orders because real reproductions show both.\n+    r\"|(?:p?kill\\b[^\\n]*\\bhermes\\b[^\\n]*\\bgateway)\"\n+    r\"|(?:p?kill\\b[^\\n]*\\bgateway\\b[^\\n]*\\bhermes)\"\n+)\n+\n+\n+def contains_gateway_lifecycle_command(text: str) -> bool:\n+    \"\"\"Return True if *text* contains a gateway lifecycle command pattern.\"\"\"\n+    if not text:\n+        return False\n+    return bool(_GATEWAY_LIFECYCLE_PATTERN.search(text))\n+\n+\n+def _resolve_script_path(script_path: str) -> Path:\n+    \"\"\"Resolve a cron ``script`` value the same way the scheduler does.\n+\n+    The scheduler (``cron.scheduler``) resolves a bare/relative script path\n+    under ``<HERMES_HOME>/scripts/`` and only accepts absolute paths as-is.\n+    We MUST mirror that here so the guard scans the file that will actually\n+    run — otherwise a job whose script lives at the scheduler's real location\n+    (``~/.hermes/scripts/restart.sh``) but is passed as the bare name\n+    ``restart.sh`` would read as a nonexistent relative path and silently\n+    scan prompt-only content, letting the command through.\n+    \"\"\"\n+    from hermes_constants import get_hermes_home\n+\n+    raw = Path(script_path).expanduser()\n+    if raw.is_absolute():\n+        return raw\n+    return get_hermes_home() / \"scripts\" / raw\n+\n+\n+def _read_script_for_scanning(script_path: str) -> str:\n+    \"\"\"Read a script file for lifecycle-pattern scanning.\n+\n+    Decodes with ``errors=\"replace\"`` so binary or non-UTF-8 content does not\n+    silently bypass the check — a plain text-mode read raises\n+    ``UnicodeDecodeError`` on such files, and swallowing that error would let\n+    an attacker hide the command in binary noise.  Returns an empty string\n+    only when the file cannot be read at all.\n+    \"\"\"\n+    try:\n+        return _resolve_script_path(script_path).read_bytes().decode(\n+            \"utf-8\", errors=\"replace\"\n+        )\n+    except OSError:\n+        return \"\"\n+\n+\n+def check_gateway_lifecycle(\n+    prompt: Optional[str],\n+    script: Optional[str] = None,\n+) -> None:\n+    \"\"\"Raise ``GatewayLifecycleBlocked`` if *prompt* or *script* contains a\n+    gateway-lifecycle command pattern.\n+\n+    ``prompt`` is scanned directly.  ``script``, when supplied, is read from\n+    disk and concatenated for the scan.  Both are considered together so a\n+    job cannot slip through by splitting the command across the prompt and\n+    the script.\n+\n+    Callers should let the exception propagate when they want the create to\n+    fail with a ``ValueError``-shaped error (the agent's ``cronjob`` tool\n+    surfaces this as a tool error; the CLI prints it in red and exits 1).\n+    \"\"\"\n+    combined = prompt or \"\"\n+    if script:\n+        script_text = _read_script_for_scanning(script)\n+        if script_text:\n+            combined = f\"{combined}\\n{script_text}\"\n+\n+    if contains_gateway_lifecycle_command(combined):\n+        raise GatewayLifecycleBlocked(\n+            \"Blocked: cron job contains a gateway lifecycle command \"\n+            \"(restart/stop/kill). This is blocked to prevent agent-driven \"\n+            \"SIGTERM-respawn loops under launchd/systemd supervision \"\n+            \"(#30719). Run `hermes gateway restart` from a shell outside \"\n+            \"the running gateway instead.\"\n+        )"]},{"file":"gateway/restart_loop_guard.py","hunks":["@@ -0,0 +1,150 @@\n+\"\"\"Auto-resume restart-loop breaker (#30719, defense-3).\n+\n+Defenses 1 and 2 (the ``_HERMES_GATEWAY`` guard on ``hermes gateway\n+stop|restart`` + ``terminal_tool``, and the cron-creation lifecycle\n+filter) stop the agent from scheduling its own restart via the cron and\n+CLI paths.  They do NOT cover every SIGTERM source: an agent running a\n+raw ``terminal(\"launchctl kickstart -k gui/<uid>/ai.hermes.gateway\")``,\n+an external monitor with a bad trigger, or any other repeated crash can\n+still drive the supervisor (launchd ``KeepAlive`` / systemd ``Restart=``)\n+into a tight respawn loop.  On each boot the gateway auto-resumes the\n+restart-interrupted session, whose next turn re-runs the offending\n+logic — SIGTERM every ~10 seconds until manually broken.\n+\n+This module is the last-resort circuit breaker: it records a timestamp\n+each time the gateway boots with restart-interrupted sessions pending,\n+keeps a rolling window of recent boots persisted across processes (each\n+boot is a fresh process, so in-memory state is useless), and reports the\n+loop as \"tripped\" once too many such boots happen inside a short window.\n+When tripped, the caller SKIPS auto-resume for that boot — the gateway\n+still starts and serves real inbound messages, it just stops replaying\n+the session that keeps killing it, which breaks the cycle and puts a\n+human back in the loop.\n+\n+State lives in ``<HERMES_HOME>/gateway/restart_loop.json`` so it is\n+profile-scoped and survives process death.  It is intentionally tiny and\n+best-effort: any read/write failure fails OPEN (no false trip) because a\n+broken breaker must never wedge a healthy gateway.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import logging\n+import time\n+from typing import List, Optional\n+\n+from hermes_constants import get_hermes_home\n+\n+logger = logging.getLogger(\"gateway.run\")\n+\n+# Defaults chosen so a legitimate operator restart (or two) never trips the\n+# breaker, but the documented ~10s respawn loop does within a few cycles.\n+DEFAULT_MAX_RESTARTS = 3\n+DEFAULT_WINDOW_SECONDS = 60\n+\n+\n+def _state_path():\n+    return get_hermes_home() / \"gateway\" / \"restart_loop.json\"\n+\n+\n+def _load_boots() -> List[float]:\n+    try:\n+        raw = _state_path().read_text(encoding=\"utf-8\")\n+        data = json.loads(raw)\n+        boots = data.get(\"boots\", [])\n+        return [float(t) for t in boots if isinstance(t, (int, float))]\n+    except (OSError, ValueError, TypeError):\n+        return []\n+\n+\n+def _save_boots(boots: List[float]) -> None:\n+    try:\n+        path = _state_path()\n+        path.parent.mkdir(parents=True, exist_ok=True)\n+        path.write_text(json.dumps({\"boots\": boots}), encoding=\"utf-8\")\n+    except OSError:\n+        pass\n+\n+\n+def record_restart_interrupted_boot(\n+    window_seconds: int = DEFAULT_WINDOW_SECONDS,\n+    *,\n+    now: Optional[float] = None,\n+) -> List[float]:\n+    \"\"\"Record that the gateway just booted with restart-interrupted sessions.\n+\n+    Prunes boots older than ``window_seconds`` and appends the current time.\n+    Returns the pruned+appended list (most recent last).  Best-effort — a\n+    persistence failure returns the in-memory list without raising.\n+    \"\"\"\n+    ts = time.time() if now is None else now\n+    cutoff = ts - max(1, window_seconds)\n+    boots = [t for t in _load_boots() if t >= cutoff]\n+    boots.append(ts)\n+    _save_boots(boots)\n+    return boots\n+\n+\n+def is_restart_loop_tripped(\n+    max_restarts: int = DEFAULT_MAX_RESTARTS,\n+    window_seconds: int = DEFAULT_WINDOW_SECONDS,\n+    *,\n+    now: Optional[float] = None,\n+) -> bool:\n+    \"\"\"Return True if the gateway has restarted ``>= max_restarts`` times with\n+    restart-interrupted sessions inside the last ``window_seconds``.\n+\n+    Reads the persisted boot log written by\n+    ``record_restart_interrupted_boot`` and counts boots within the window.\n+    Fails OPEN (returns False) on any error — a broken breaker must never\n+    wedge a healthy gateway.\n+    \"\"\"\n+    if max_restarts <= 0:\n+        return False\n+    ts = time.time() if now is None else now\n+    cutoff = ts - max(1, window_seconds)\n+    try:\n+        recent = [t for t in _load_boots() if t >= cutoff]\n+    except Exception:  # pragma: no cover — _load_boots already guards\n+        return False\n+    return len(recent) >= max_restarts\n+\n+\n+def clear() -> None:\n+    \"\"\"Remove the persisted boot log (used on clean shutdown / by tests).\"\"\"\n+    try:\n+        _state_path().unlink(missing_ok=True)\n+    except OSError:\n+        pass\n+\n+\n+def check_and_record(\n+    max_restarts: int = DEFAULT_MAX_RESTARTS,\n+    window_seconds: int = DEFAULT_WINDOW_SECONDS,\n+    *,\n+    now: Optional[float] = None,\n+) -> bool:\n+    \"\"\"Record this restart-interrupted boot and report whether the loop is now\n+    tripped.\n+\n+    This is the single entry point the gateway calls: it appends the current\n+    boot, then checks whether the (now-updated) window has reached the\n+    threshold.  Returns True when auto-resume should be SKIPPED to break the\n+    loop.\n+    \"\"\"\n+    boots = record_restart_interrupted_boot(window_seconds, now=now)\n+    tripped = len(boots) >= max_restarts if max_restarts > 0 else False\n+    if tripped:\n+        logger.warning(\n+            \"Restart-loop breaker TRIPPED: %d restart-interrupted gateway \"\n+            \"boots within %ds (threshold %d). Skipping auto-resume to break \"\n+            \"a suspected SIGTERM-respawn loop (#30719). Restart-interrupted \"\n+            \"sessions stay resume-pending and will continue on the next real \"\n+            \"user message. If this is a false positive, delete %s.\",\n+            len(boots),\n+            window_seconds,\n+            max_restarts,\n+            _state_path(),\n+        )\n+    return tripped"]},{"file":"gateway/run.py","hunks":["@@ -3800,6 +3800,29 @@ def _scale_to_zero_idle_timeout_seconds(self) -> float:\n             raw = None\n         return parse_idle_timeout_seconds(raw)\n \n+    def _restart_loop_guard_config(self) -> tuple:\n+        \"\"\"Return ``(max_restarts, window_seconds)`` for the auto-resume\n+        restart-loop breaker (#30719, defense-3), read from\n+        ``gateway.restart_loop_guard`` in config.yaml with the module defaults\n+        as fallback. ``max_restarts <= 0`` disables the breaker.\n+        \"\"\"\n+        from gateway import restart_loop_guard as _rlg\n+\n+        max_restarts = _rlg.DEFAULT_MAX_RESTARTS\n+        window_seconds = _rlg.DEFAULT_WINDOW_SECONDS\n+        try:\n+            user_cfg = _load_gateway_config()\n+            gw = user_cfg.get(\"gateway\") if isinstance(user_cfg, dict) else None\n+            rlg = gw.get(\"restart_loop_guard\") if isinstance(gw, dict) else None\n+            if isinstance(rlg, dict):\n+                if isinstance(rlg.get(\"max_restarts\"), int):\n+                    max_restarts = rlg[\"max_restarts\"]\n+                if isinstance(rlg.get(\"window_seconds\"), int) and rlg[\"window_seconds\"] > 0:\n+                    window_seconds = rlg[\"window_seconds\"]\n+        except Exception:  # noqa: BLE001\n+            pass\n+        return max_restarts, window_seconds\n+\n     def _scale_to_zero_should_arm(self) -> bool:\n         \"\"\"Whether to start the idle watcher (D1/D11/§3.4(1)).\"\"\"\n         from gateway.relay import relay_wake_url","@@ -5961,6 +5984,26 @@ def _schedule_resume_pending_sessions(self, platform=None) -> int:\n             logger.warning(\"Failed to enumerate resume-pending sessions: %s\", exc)\n             return 0\n \n+        # Defense-3 (#30719): break the SIGTERM-respawn loop. Only count this\n+        # boot when there are restart-interrupted sessions to resume — a clean\n+        # boot must not accrue toward the breaker. If too many such boots have\n+        # happened in the configured window, skip auto-resume for THIS boot:\n+        # the gateway still comes up and serves real inbound messages, it just\n+        # stops replaying the session that keeps killing it. The session stays\n+        # resume_pending, so a real user message can still continue it (a human\n+        # is now in the loop). Defenses 1-2 cover the cron/CLI/terminal paths;\n+        # this catches every other SIGTERM source (e.g. a raw `terminal(\n+        # \"launchctl kickstart ai.hermes.gateway\")`).\n+        if candidates:\n+            try:\n+                from gateway import restart_loop_guard as _rlg\n+\n+                _max_restarts, _window = self._restart_loop_guard_config()\n+                if _rlg.check_and_record(_max_restarts, _window):\n+                    return 0\n+            except Exception as exc:  # noqa: BLE001 — breaker must fail OPEN\n+                logger.debug(\"Restart-loop guard check skipped: %s\", exc)\n+\n         now = datetime.now()\n         scheduled = 0\n         for entry in candidates:"]},{"file":"hermes_cli/config.py","hunks":["@@ -2717,6 +2717,23 @@ def _ensure_hermes_home_managed(home: Path):\n             \"idle_timeout_minutes\": 5,\n         },\n \n+        # Auto-resume restart-loop breaker (#30719, defense-3). When the\n+        # gateway is killed mid-turn (SIGTERM) and revived by a supervisor\n+        # (launchd KeepAlive / systemd Restart=), it auto-resumes the\n+        # restart-interrupted session on the next boot. If the resumed turn\n+        # keeps triggering another kill (e.g. the agent runs a raw\n+        # `launchctl kickstart ai.hermes.gateway` that defenses 1-2 don't\n+        # cover), the result is a tight SIGTERM-respawn loop. This breaker\n+        # counts restart-interrupted boots in a rolling window and, once\n+        # `max_restarts` boots happen within `window_seconds`, SKIPS\n+        # auto-resume for that boot — the gateway still starts and serves\n+        # real inbound messages, it just stops replaying the session that\n+        # keeps killing it. Set `max_restarts` to 0 to disable the breaker.\n+        \"restart_loop_guard\": {\n+            \"max_restarts\": 3,\n+            \"window_seconds\": 60,\n+        },\n+\n         # Inject a human-readable timestamp prefix (e.g.\n         # \"[Tue 2026-04-28 13:40:53 CEST]\") onto user messages IN THE MODEL'S\n         # CONTEXT so the agent has temporal awareness of when each message was"]},{"file":"hermes_cli/cron.py","hunks":["@@ -6,7 +6,6 @@\n \"\"\"\n \n import json\n-import re\n import sys\n from pathlib import Path\n from typing import Iterable, List, Optional","@@ -16,25 +15,17 @@\n \n from hermes_cli.colors import Colors, color\n \n-# Patterns that indicate a cron job targets the gateway lifecycle.\n-# Matches commands that restart/stop the gateway or its service manager.\n-# Deliberately specific — a bare \"gateway ... restart\" catch-all would block\n-# legitimate prompts that merely mention an unrelated gateway (e.g. \"summarize\n-# the API gateway logs and report restart events\").\n-_GATEWAY_LIFECYCLE_PATTERNS = re.compile(\n-    r\"(?i)\"\n-    r\"(hermes\\s+gateway\\s+(restart|stop|start))\"\n-    r\"|(launchctl\\s+(kickstart|unload|load|stop|restart)\\s+.*hermes)\"\n-    r\"|(systemctl\\s+(-\\S+\\s+)*(restart|stop|start)\\s+.*hermes)\"\n-    r\"|(p?kill\\s+.*hermes.*gateway)\"\n+# Gateway-lifecycle command detection lives in ``cron.lifecycle_guard`` so it\n+# can be shared across every job-creation path (CLI + the agent's ``cronjob``\n+# model tool via ``cron.jobs.create_job``) without a circular import. Re-export\n+# ``_contains_gateway_lifecycle_command`` here for back-compat: ``tools/\n+# terminal_tool.py`` imports it from this module to hard-block the same\n+# commands at execution time when ``_HERMES_GATEWAY=1``.\n+from cron.lifecycle_guard import (  # noqa: F401  (re-exported for terminal_tool)\n+    contains_gateway_lifecycle_command as _contains_gateway_lifecycle_command,\n )\n \n \n-def _contains_gateway_lifecycle_command(text: str) -> bool:\n-    \"\"\"Return True if *text* contains a gateway lifecycle command pattern.\"\"\"\n-    return bool(_GATEWAY_LIFECYCLE_PATTERNS.search(text))\n-\n-\n def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:\n     if skills is None:\n         if single_skill is None:","@@ -301,28 +292,12 @@ def _print_active_jobs_summary(jobs) -> None:\n \n \n def cron_create(args):\n-    # Defense: reject cron jobs that contain gateway lifecycle commands.\n-    # Prevents agents from scheduling their own restart/stop, which creates\n-    # SIGTERM-respawn loops under launchd/systemd KeepAlive (#30719).\n-    prompt = getattr(args, \"prompt\", None) or \"\"\n-    script = getattr(args, \"script\", None)\n-    combined = prompt\n-    if script:\n-        try:\n-            script_text = Path(script).read_text(encoding=\"utf-8\")\n-            combined = f\"{combined}\\n{script_text}\"\n-        except (OSError, UnicodeDecodeError):\n-            pass\n-    if _contains_gateway_lifecycle_command(combined):\n-        print(color(\n-            \"Blocked: cron job contains a gateway lifecycle command \"\n-            \"(restart/stop/kill).\\n\"\n-            \"This is blocked to prevent restart loops (#30719).\\n\"\n-            \"Use `hermes gateway restart` from a shell outside the gateway.\",\n-            Colors.RED,\n-        ))\n-        return 1\n-\n+    # The gateway-lifecycle guard lives in cron.jobs.create_job so it fires on\n+    # every job-creation path (this CLI subcommand AND the agent's `cronjob`\n+    # model tool, which calls create_job directly). When it blocks, create_job\n+    # raises GatewayLifecycleBlocked, the `cronjob` tool wrapper catches it and\n+    # returns it as result[\"error\"], and the `if not result.get(\"success\")`\n+    # branch below prints it in red and exits 1 — same UX as before.\n     result = _cron_api(\n         action=\"create\",\n         schedule=args.schedule,"]},{"file":"tests/hermes_cli/test_gateway_restart_loop.py","hunks":["@@ -28,7 +28,6 @@ class TestGatewayLifecyclePattern:\n     @pytest.mark.parametrize(\"text\", [\n         \"hermes gateway restart\",\n         \"hermes gateway stop\",\n-        \"hermes gateway start\",\n         \"hermes  gateway  restart\",         # double spaces\n         \"Hermez Gateway Restart\".lower().replace(\"z\", \"s\"),  # case handled\n         \"HERMES GATEWAY RESTART\",           # uppercase","@@ -50,6 +49,7 @@ def test_service_manager_commands(self, text):\n     @pytest.mark.parametrize(\"text\", [\n         \"kill hermes gateway process\",\n         \"pkill -f hermes.*gateway\",\n+        \"pkill -f gateway.*hermes\",          # inverse token order\n     ])\n     def test_kill_commands(self, text):\n         assert _contains_gateway_lifecycle_command(text), f\"Should match: {text!r}\"","@@ -62,11 +62,28 @@ def test_kill_commands(self, text):\n         \"echo 'just a normal cron job'\",\n         \"run the backup script\",\n         \"gateway is running fine\",\n+        # `hermes gateway start` is benign — starting a gateway from inside a\n+        # gateway is a no-op / \"already running\", and a legit cron job may\n+        # start a sibling profile's gateway. Only restart/stop/kill are the\n+        # foot-gun (#30719 lists only those).\n+        \"hermes gateway start\",\n+        \"hermes gateway start --all\",\n+        # Tightened launchctl/systemctl branches: ops on NON-gateway hermes\n+        # services must not be falsely blocked (the old `.*hermes` matched any\n+        # hermes token).\n+        \"launchctl unload ai.hermes.update-checker.plist\",\n+        \"launchctl restart ai.hermes.daemon\",\n+        \"systemctl restart hermes-meta.service\",\n+        \"systemctl restart hermes-cron-helper\",\n         # Regression (#30728 follow-up): legit prompts that merely mention an\n-        # unrelated gateway + a restart must NOT be blocked.\n+        # unrelated gateway + a restart must NOT be blocked. The cron prompt is\n+        # fed to an LLM, not a shell, so substring detection on English text is\n+        # a high-FP no-op — only concrete command shapes trigger the block.\n         \"Summarize the API gateway logs and report any restart events from last night\",\n         \"Check if the payment gateway needs a restart after the deploy\",\n         \"Monitor the gateway and tell me if a restart is recommended\",\n+        \"research how the OpenAI API gateway handles restart after rate limiting\",\n+        \"compare AWS API Gateway vs Cloudflare on restart latency\",\n     ])\n     def test_safe_commands(self, text):\n         assert not _contains_gateway_lifecycle_command(text), f\"Should NOT match: {text!r}\"","@@ -122,9 +139,14 @@ def test_block_launchctl_kickstart(self, capsys):\n         out = capsys.readouterr().out\n         assert \"Blocked\" in out\n \n-    def test_block_script_with_lifecycle_command(self, tmp_path, capsys):\n-        script = tmp_path / \"restart.sh\"\n-        script.write_text(\"#!/bin/bash\\nhermes gateway restart\\n\")\n+    def test_block_script_with_lifecycle_command(self, tmp_path, capsys, monkeypatch):\n+        # A no_agent job whose script IS the job (the issue's real abuse path:\n+        # restart_hermes_gateway_once.sh). The script must live under\n+        # HERMES_HOME/scripts so the scheduler — and the guard — resolve it.\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \".hermes\"))\n+        scripts_dir = tmp_path / \".hermes\" / \"scripts\"\n+        scripts_dir.mkdir(parents=True)\n+        (scripts_dir / \"restart.sh\").write_text(\"#!/bin/bash\\nhermes gateway restart\\n\")\n         args = Namespace(\n             cron_command=\"create\",\n             schedule=\"1h\",","@@ -134,10 +156,10 @@ def test_block_script_with_lifecycle_command(self, tmp_path, capsys):\n             repeat=None,\n             skill=None,\n             skills=None,\n-            script=str(script),\n+            script=\"restart.sh\",\n             workdir=None,\n             profile=None,\n-            no_agent=False,\n+            no_agent=True,\n         )\n         rc = cron_command(args)\n         assert rc == 1","@@ -357,3 +379,155 @@ def execute(self, command, **kwargs):\n         # approval flow handles it (here mocked as approved).\n         assert result[\"exit_code\"] == 0\n         assert calls == [\"systemctl restart hermes-gateway\"]\n+\n+\n+# ---------------------------------------------------------------------------\n+# cron.lifecycle_guard module — the shared checker create_job/CLI/terminal use\n+# ---------------------------------------------------------------------------\n+\n+class TestLifecycleGuardModule:\n+    \"\"\"Direct tests for cron.lifecycle_guard.check_gateway_lifecycle.\"\"\"\n+\n+    def test_prompt_with_command_raises(self):\n+        from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle\n+        with pytest.raises(GatewayLifecycleBlocked) as exc:\n+            check_gateway_lifecycle(\"please run hermes gateway restart\", None)\n+        assert \"#30719\" in str(exc.value)\n+\n+    def test_clean_prompt_does_not_raise(self):\n+        from cron.lifecycle_guard import check_gateway_lifecycle\n+        check_gateway_lifecycle(\"research the gateway architecture\", None)\n+        check_gateway_lifecycle(\"check server health and restart watchers\", None)\n+\n+    def test_script_with_command_raises(self, tmp_path, monkeypatch):\n+        from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle\n+        script = tmp_path / \"restart.sh\"\n+        script.write_text(\"#!/bin/bash\\nhermes gateway restart\\n\")\n+        with pytest.raises(GatewayLifecycleBlocked):\n+            check_gateway_lifecycle(\"clean prompt\", str(script))\n+\n+    def test_split_across_prompt_and_script_still_blocks(self, tmp_path):\n+        \"\"\"Concatenated scan prevents splitting the command between prompt and\n+        script to slip through.\"\"\"\n+        from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle\n+        script = tmp_path / \"ops.sh\"\n+        script.write_text(\"hermes gateway stop\\n\")\n+        with pytest.raises(GatewayLifecycleBlocked):\n+            check_gateway_lifecycle(\"daily ops job\", str(script))\n+\n+    def test_binary_script_does_not_silently_bypass(self, tmp_path):\n+        \"\"\"Non-UTF-8 bytes used to be swallowed by UnicodeDecodeError; now we\n+        decode with errors='replace' so the scan always sees the command.\"\"\"\n+        from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle\n+        script = tmp_path / \"weird.bin\"\n+        script.write_bytes(b\"\\xfehermes gateway restart\\xff\")\n+        with pytest.raises(GatewayLifecycleBlocked):\n+            check_gateway_lifecycle(\"\", str(script))\n+\n+    def test_missing_script_does_not_raise(self, tmp_path):\n+        from cron.lifecycle_guard import check_gateway_lifecycle\n+        check_gateway_lifecycle(\"clean prompt\", str(tmp_path / \"nonexistent.sh\"))\n+\n+    def test_relative_script_resolved_under_scripts_dir(self, tmp_path, monkeypatch):\n+        \"\"\"A bare/relative script name resolves under HERMES_HOME/scripts (the\n+        same place the scheduler runs it from) — otherwise the guard would read\n+        a nonexistent relative path and scan prompt-only content.\"\"\"\n+        from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \".hermes\"))\n+        scripts_dir = tmp_path / \".hermes\" / \"scripts\"\n+        scripts_dir.mkdir(parents=True)\n+        (scripts_dir / \"restart.sh\").write_text(\n+            \"launchctl kickstart -k gui/501/ai.hermes.gateway\\n\"\n+        )\n+        with pytest.raises(GatewayLifecycleBlocked):\n+            check_gateway_lifecycle(\"daily\", \"restart.sh\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# Defense 2 (chokepoint): cron.jobs.create_job blocks the AGENT model-tool path\n+# ---------------------------------------------------------------------------\n+\n+class TestCreateJobBlocksLifecycleCommands:\n+    \"\"\"The regression the CLI-layer-only guard could not catch: the agent's\n+    `cronjob` model tool calls cron.jobs.create_job directly, bypassing\n+    hermes_cli.cron.cron_create. Enforcing at create_job covers both.\"\"\"\n+\n+    @pytest.fixture(autouse=True)\n+    def _setup_cron_dir(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(\"cron.jobs.CRON_DIR\", tmp_path / \"cron\")\n+        monkeypatch.setattr(\"cron.jobs.JOBS_FILE\", tmp_path / \"cron\" / \"jobs.json\")\n+        monkeypatch.setattr(\"cron.jobs.OUTPUT_DIR\", tmp_path / \"cron\" / \"output\")\n+\n+    def test_create_job_blocks_prompt_command(self):\n+        from cron.jobs import create_job\n+        from cron.lifecycle_guard import GatewayLifecycleBlocked\n+        with pytest.raises(GatewayLifecycleBlocked):\n+            create_job(prompt=\"then run hermes gateway restart\", schedule=\"30m\")\n+\n+    def test_create_job_allows_benign_prompt(self):\n+        from cron.jobs import create_job\n+        job = create_job(prompt=\"summarize the API gateway logs and note restart events\",\n+                         schedule=\"30m\")\n+        assert job[\"id\"]\n+\n+    def test_cronjob_tool_surfaces_block_as_error(self, tmp_path, monkeypatch):\n+        \"\"\"End-to-end through the model tool: the block comes back as\n+        result['error'] with the #30719 hint, not an unhandled exception.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \".hermes\"))\n+        (tmp_path / \".hermes\").mkdir(parents=True)\n+        from tools.cronjob_tools import cronjob\n+        result = json.loads(cronjob(\n+            action=\"create\", schedule=\"0 9 * * *\",\n+            prompt=\"please run hermes gateway restart nightly\",\n+        ))\n+        assert result.get(\"success\") is False\n+        assert \"#30719\" in result.get(\"error\", \"\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# Defense 3: auto-resume restart-loop breaker\n+# ---------------------------------------------------------------------------\n+\n+class TestRestartLoopGuard:\n+    \"\"\"gateway.restart_loop_guard trips after >= max_restarts\n+    restart-interrupted boots inside window_seconds, breaking a\n+    SIGTERM-respawn loop that defenses 1-2 don't cover.\"\"\"\n+\n+    @pytest.fixture(autouse=True)\n+    def _isolate_state(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \".hermes\"))\n+        (tmp_path / \".hermes\").mkdir(parents=True)\n+        import gateway.restart_loop_guard as rlg\n+        rlg.clear()\n+\n+    def test_burst_trips_on_threshold(self):\n+        import gateway.restart_loop_guard as rlg\n+        assert rlg.check_and_record(3, 60, now=1000.0) is False\n+        assert rlg.check_and_record(3, 60, now=1005.0) is False\n+        assert rlg.check_and_record(3, 60, now=1010.0) is True\n+\n+    def test_spread_boots_never_trip(self):\n+        import gateway.restart_loop_guard as rlg\n+        assert rlg.check_and_record(3, 60, now=1000.0) is False\n+        assert rlg.check_and_record(3, 60, now=1070.0) is False\n+        assert rlg.check_and_record(3, 60, now=1140.0) is False\n+\n+    def test_disabled_when_max_restarts_zero(self):\n+        import gateway.restart_loop_guard as rlg\n+        for i in range(5):\n+            assert rlg.check_and_record(0, 60, now=1000.0 + i) is False\n+\n+    def test_is_tripped_reads_without_recording(self):\n+        import gateway.restart_loop_guard as rlg\n+        rlg.record_restart_interrupted_boot(60, now=1000.0)\n+        rlg.record_restart_interrupted_boot(60, now=1001.0)\n+        assert rlg.is_restart_loop_tripped(3, 60, now=1002.0) is False\n+        rlg.record_restart_interrupted_boot(60, now=1002.0)\n+        assert rlg.is_restart_loop_tripped(3, 60, now=1003.0) is True\n+\n+    def test_clear_resets(self):\n+        import gateway.restart_loop_guard as rlg\n+        rlg.check_and_record(3, 60, now=1000.0)\n+        rlg.check_and_record(3, 60, now=1001.0)\n+        rlg.clear()\n+        assert rlg.check_and_record(3, 60, now=1002.0) is False\n"]}]},{"kind":"pr","number":59343,"title":"fix: prevent no_agent one-shot cron jobs from re-firing on gateway re…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"84c724d69","number":0},{"kind":"pr","value":"#56177","number":56177}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}