{"repo":"NousResearch/hermes-agent","public_id":"4262","display_label":"HERMES_CRON_SESSION env var leaking into interactive sessions","summary":"A cluster of bug reports, investigations, and fix attempts centered on HERMES_CRON_SESSION being set as a process-global environment variable by the cron scheduler, causing it to leak into concurrent or subsequent interactive gateway sessions and affect their approval behavior. Multiple fix approaches are represented, including cleanup after each job, per-job ContextVar isolation, and ignoring the leaked value in interactive contexts.","subsystem":"agent","counts":{"members":31,"open_issues":6,"open_issues_distinct":6,"open_prs":8,"closed_issues":6,"merged_prs":2,"attempt_prs":17},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":13021},"resolution":{"state":"partially_resolved","by":"pr-77022"},"freshness":{"state":"verified","last_verified_at":"2026-08-02T18:55:21+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.06804301075268816},"fix_edges":[{"issue_number":9086,"pr_number":13021,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":10769,"pr_number":13021,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":37968,"pr_number":77022,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":4262,"title":"Bug: Cron job environment variables leak between sequential jobs","url":null,"state":"closed"},{"kind":"issue","number":8866,"title":"Cron delivery target can be hijacked by current home channel even when deliver is explicitly set","url":null,"state":"closed"},{"kind":"issue","number":10769,"title":"[bug]: Cross-talk and Identity Leakage in Cron Job Delivery due to Global Environment Variable Usage","url":null,"state":"closed"},{"kind":"issue","number":37968,"title":"fix(cron): isolate gateway approvals from environment pollution","url":null,"state":"closed"},{"kind":"issue","number":56771,"title":"[Bug]: `execute_code` blocked in interactive Telegram gateway session because `HERMES_CRON_SESSION` env var leaks from cron scheduler into user's shell","url":null,"state":"open"},{"kind":"issue","number":57736,"title":"HERMES_CRON_SESSION set process-globally: interactive gateway sessions inherit cron approval policy after any cron runs","url":null,"state":"open"},{"kind":"issue","number":58662,"title":"[Bug]: in-process cron ticker leaks its approval marker into interactive gateway sessions","url":null,"state":"closed"},{"kind":"issue","number":59236,"title":"Follow up: avoid process-wide cron marker leaking into later interactive gateway approvals","url":null,"state":"open"},{"kind":"issue","number":60350,"title":"[Bug] execute_code blocked in WeCom gateway session — HERMES_CRON_SESSION env var leaks from in-process cron ticker (corroborating #56771)","url":null,"state":"open"},{"kind":"issue","number":60997,"title":"fix: HERMES_CRON_SESSION env var leaks into global process context, blocking execute_code in user sessions","url":null,"state":"open"},{"kind":"issue","number":62905,"title":"Cron tool calls can route to interactive approval (pending_approval) after a gateway restart, yielding silent no-op job output","url":null,"state":"closed"},{"kind":"issue","number":68286,"title":"[Bug]: 2 commands one by one mixed actions together- halucinated","url":null,"state":"open"},{"kind":"pr","number":10795,"title":"fix(cron): eliminate os.environ race in concurrent job delivery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c86915024","number":0},{"kind":"pr","value":"#13021","number":13021}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":10808,"title":"Isolate cron delivery context per scheduled job","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c86915024","number":0},{"kind":"pr","value":"#13021","number":13021},{"kind":"pr","value":"#10769","number":10769},{"kind":"pr","value":"#10769","number":10769}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":13021,"title":"fix(cron): run due jobs in parallel to prevent serial tick starvation","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","gateway/session_context.py","hermes_cli/config.py","tests/cron/test_scheduler.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -9,6 +9,7 @@\n import json\n import logging\n import tempfile\n+import threading\n import os\n import re\n import uuid","@@ -34,6 +35,11 @@\n HERMES_DIR = get_hermes_home().resolve()\n CRON_DIR = HERMES_DIR / \"cron\"\n JOBS_FILE = CRON_DIR / \"jobs.json\"\n+\n+# In-process lock protecting load_jobs→modify→save_jobs cycles.\n+# Required when tick() runs jobs in parallel threads — without this,\n+# concurrent mark_job_run / advance_next_run calls can clobber each other.\n+_jobs_file_lock = threading.Lock()\n OUTPUT_DIR = CRON_DIR / \"output\"\n ONESHOT_GRACE_SECONDS = 120\n ","@@ -594,43 +600,44 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,\n     ``delivery_error`` is tracked separately from the agent error — a job\n     can succeed (agent produced output) but fail delivery (platform down).\n     \"\"\"\n-    jobs = load_jobs()\n-    for i, job in enumerate(jobs):\n-        if job[\"id\"] == job_id:\n-            now = _hermes_now().isoformat()\n-            job[\"last_run_at\"] = now\n-            job[\"last_status\"] = \"ok\" if success else \"error\"\n-            job[\"last_error\"] = error if not success else None\n-            # Track delivery failures separately — cleared on successful delivery\n-            job[\"last_delivery_error\"] = delivery_error\n-            \n-            # Increment completed count\n-            if job.get(\"repeat\"):\n-                job[\"repeat\"][\"completed\"] = job[\"repeat\"].get(\"completed\", 0) + 1\n+    with _jobs_file_lock:\n+        jobs = load_jobs()\n+        for i, job in enumerate(jobs):\n+            if job[\"id\"] == job_id:\n+                now = _hermes_now().isoformat()\n+                job[\"last_run_at\"] = now\n+                job[\"last_status\"] = \"ok\" if success else \"error\"\n+                job[\"last_error\"] = error if not success else None\n+                # Track delivery failures separately — cleared on successful delivery\n+                job[\"last_delivery_error\"] = delivery_error\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)\n-                    save_jobs(jobs)\n-                    return\n-            \n-            # Compute next run\n-            job[\"next_run_at\"] = compute_next_run(job[\"schedule\"], now)\n+                # Increment completed count\n+                if job.get(\"repeat\"):\n+                    job[\"repeat\"][\"completed\"] = job[\"repeat\"].get(\"completed\", 0) + 1\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)\n+                        save_jobs(jobs)\n+                        return\n+                \n+                # Compute next run\n+                job[\"next_run_at\"] = compute_next_run(job[\"schedule\"], now)\n \n-            # If no next run (one-shot completed), disable\n-            if job[\"next_run_at\"] is None:\n-                job[\"enabled\"] = False\n-                job[\"state\"] = \"completed\"\n-            elif job.get(\"state\") != \"paused\":\n-                job[\"state\"] = \"scheduled\"\n+                # If no next run (one-shot completed), disable\n+                if job[\"next_run_at\"] is None:\n+                    job[\"enabled\"] = False\n+                    job[\"state\"] = \"completed\"\n+                elif job.get(\"state\") != \"paused\":\n+                    job[\"state\"] = \"scheduled\"\n \n-            save_jobs(jobs)\n-            return\n+                save_jobs(jobs)\n+                return\n \n-    logger.warning(\"mark_job_run: job_id %s not found, skipping save\", job_id)\n+        logger.warning(\"mark_job_run: job_id %s not found, skipping save\", job_id)\n \n \n def advance_next_run(job_id: str) -> bool:","@@ -645,20 +652,21 @@ def advance_next_run(job_id: str) -> bool:\n \n     Returns True if next_run_at was advanced, False otherwise.\n     \"\"\"\n-    jobs = load_jobs()\n-    for job in jobs:\n-        if job[\"id\"] == job_id:\n-            kind = job.get(\"schedule\", {}).get(\"kind\")\n-            if kind not in (\"cron\", \"interval\"):\n+    with _jobs_file_lock:\n+        jobs = load_jobs()\n+        for job in jobs:\n+            if job[\"id\"] == job_id:\n+                kind = job.get(\"schedule\", {}).get(\"kind\")\n+                if kind not in (\"cron\", \"interval\"):\n+                    return False\n+                now = _hermes_now().isoformat()\n+                new_next = compute_next_run(job[\"schedule\"], now)\n+                if new_next and new_next != job.get(\"next_run_at\"):\n+                    job[\"next_run_at\"] = new_next\n+                    save_jobs(jobs)\n+                    return True\n                 return False\n-            now = _hermes_now().isoformat()\n-            new_next = compute_next_run(job[\"schedule\"], now)\n-            if new_next and new_next != job.get(\"next_run_at\"):\n-                job[\"next_run_at\"] = new_next\n-                save_jobs(jobs)\n-                return True\n-            return False\n-    return False\n+        return False\n \n \n def get_due_jobs() -> List[Dict[str, Any]]:"]},{"file":"cron/scheduler.py","hunks":["@@ -747,14 +747,17 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n     # scheduler process — every job this process runs is a cron job.\n     os.environ[\"HERMES_CRON_SESSION\"] = \"1\"\n \n+    # Use ContextVars for per-job session/delivery state so parallel jobs\n+    # don't clobber each other's targets (os.environ is process-global).\n+    from gateway.session_context import set_session_vars, clear_session_vars, _VAR_MAP\n+\n+    _ctx_tokens = [redacted:secret-assignment]\n+        platform=origin[\"platform\"] if origin else \"\",\n+        chat_id=str(origin[\"chat_id\"]) if origin else \"\",\n+        chat_name=origin.get(\"chat_name\", \"\") if origin else \"\",\n+    )\n+\n     try:\n-        # Inject origin context so the agent's send_message tool knows the chat.\n-        # Must be INSIDE the try block so the finally cleanup always runs.\n-        if origin:\n-            os.environ[\"HERMES_SESSION_PLATFORM\"] = origin[\"platform\"]\n-            os.environ[\"HERMES_SESSION_CHAT_ID\"] = str(origin[\"chat_id\"])\n-            if origin.get(\"chat_name\"):\n-                os.environ[\"HERMES_SESSION_CHAT_NAME\"] = origin[\"chat_name\"]\n         # Re-read .env and config.yaml fresh every run so provider/key\n         # changes take effect without a gateway restart.\n         from dotenv import load_dotenv","@@ -765,10 +768,10 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n \n         delivery_target = _resolve_delivery_target(job)\n         if delivery_target:\n-            os.environ[\"HERMES_CRON_AUTO_DELIVER_PLATFORM\"] = delivery_target[\"platform\"]\n-            os.environ[\"HERMES_CRON_AUTO_DELIVER_CHAT_ID\"] = str(delivery_target[\"chat_id\"])\n+            _VAR_MAP[\"HERMES_CRON_AUTO_DELIVER_PLATFORM\"].set(delivery_target[\"platform\"])\n+            _VAR_MAP[\"HERMES_CRON_AUTO_DELIVER_CHAT_ID\"].set(str(delivery_target[\"chat_id\"]))\n             if delivery_target.get(\"thread_id\") is not None:\n-                os.environ[\"HERMES_CRON_AUTO_DELIVER_THREAD_ID\"] = str(delivery_target[\"thread_id\"])\n+                _VAR_MAP[\"HERMES_CRON_AUTO_DELIVER_THREAD_ID\"].set(str(delivery_target[\"thread_id\"]))\n \n         model = job.get(\"model\") or os.getenv(\"HERMES_MODEL\") or \"\"\n ","@@ -1012,16 +1015,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n         return False, output, \"\", error_msg\n \n     finally:\n-        # Clean up injected env vars so they don't leak to other jobs\n-        for key in (\n-            \"HERMES_SESSION_PLATFORM\",\n-            \"HERMES_SESSION_CHAT_ID\",\n-            \"HERMES_SESSION_CHAT_NAME\",\n-            \"HERMES_CRON_AUTO_DELIVER_PLATFORM\",\n-            \"HERMES_CRON_AUTO_DELIVER_CHAT_ID\",\n-            \"HERMES_CRON_AUTO_DELIVER_THREAD_ID\",\n-        ):\n-            os.environ.pop(key, None)\n+        # Clean up ContextVar session/delivery state for this job.\n+        clear_session_vars(_ctx_tokens)\n         if _session_db:\n             try:\n                 _session_db.end_session(_cron_session_id, \"cron_complete\")","@@ -1074,15 +1069,42 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:\n         if verbose:\n             logger.info(\"%s - %s job(s) due\", _hermes_now().strftime('%H:%M:%S'), len(due_jobs))\n \n-        executed = 0\n+        # Advance next_run_at for all recurring jobs FIRST, under the file lock,\n+        # before any execution begins.  This preserves at-most-once semantics.\n         for job in due_jobs:\n+            advance_next_run(job[\"id\"])\n+\n+        # Resolve max parallel workers: env var > config.yaml > unbounded.\n+        # Set HERMES_CRON_MAX_PARALLEL=1 to restore old serial behaviour.\n+        _max_workers: Optional[int] = None\n+        try:\n+            _env_par = os.getenv(\"HERMES_CRON_MAX_PARALLEL\", \"\").strip()\n+            if _env_par:\n+                _max_workers = int(_env_par) or None\n+        except (ValueError, TypeError):\n+            logger.warning(\"Invalid HERMES_CRON_MAX_PARALLEL value; defaulting to unbounded\")\n+        if _max_workers is None:\n             try:\n-                # For recurring jobs (cron/interval), advance next_run_at to the\n-                # next future occurrence BEFORE execution.  This way, if the\n-                # process crashes mid-run, the job won't re-fire on restart.\n-                # One-shot jobs are left alone so they can retry on restart.\n-                advance_next_run(job[\"id\"])\n+                from hermes_cli.config import load_config\n+                _ucfg = load_config() or {}\n+                _cfg_par = (\n+                    _ucfg.get(\"cron\", {}) if isinstance(_ucfg, dict) else {}\n+                ).get(\"max_parallel_jobs\")\n+                if _cfg_par is not None:\n+                    _max_workers = int(_cfg_par) or None\n+            except Exception:\n+                pass\n \n+        if verbose:\n+            logger.info(\n+                \"Running %d job(s) in parallel (max_workers=%s)\",\n+                len(due_jobs),\n+                _max_workers if _max_workers else \"unbounded\",\n+            )\n+\n+        def _process_job(job: dict) -> bool:\n+            \"\"\"Run one due job end-to-end: execute, save, deliver, mark.\"\"\"\n+            try:\n                 success, output, final_response, error = run_job(job)\n \n                 output_file = save_job_output(job[\"id\"], output)","@@ -1114,13 +1136,23 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:\n                     error = \"Agent completed but produced empty response (model error, timeout, or misconfiguration)\"\n \n                 mark_job_run(job[\"id\"], success, error, delivery_error=delivery_error)\n-                executed += 1\n+                return True\n \n             except Exception as e:\n                 logger.error(\"Error processing job %s: %s\", job['id'], e)\n                 mark_job_run(job[\"id\"], False, str(e))\n-\n-        return executed\n+                return False\n+\n+        # Run all due jobs concurrently, each in its own ContextVar copy\n+        # so session/delivery state stays isolated per-thread.\n+        with concurrent.futures.ThreadPoolExecutor(max_workers=_max_workers) as _tick_pool:\n+            _futures = []\n+            for job in due_jobs:\n+                _ctx = contextvars.copy_context()\n+                _futures.append(_tick_pool.submit(_ctx.run, _process_job, job))\n+            _results = [f.result() for f in _futures]\n+\n+        return sum(_results)\n     finally:\n         if fcntl:\n             fcntl.flock(lock_fd, fcntl.LOCK_UN)"]},{"file":"gateway/session_context.py","hunks":["@@ -56,6 +56,12 @@\n _SESSION_USER_NAME: ContextVar = ContextVar(\"HERMES_SESSION_USER_NAME\", default=_UNSET)\n _SESSION_KEY: ContextVar = ContextVar(\"HERMES_SESSION_KEY\", default=_UNSET)\n \n+# Cron auto-delivery vars — set per-job in run_job() so concurrent jobs\n+# don't clobber each other's delivery targets.\n+_CRON_AUTO_DELIVER_PLATFORM: ContextVar = ContextVar(\"HERMES_CRON_AUTO_DELIVER_PLATFORM\", default=_UNSET)\n+_CRON_AUTO_DELIVER_CHAT_ID: ContextVar = ContextVar(\"HERMES_CRON_AUTO_DELIVER_CHAT_ID\", default=_UNSET)\n+_CRON_AUTO_DELIVER_THREAD_ID: ContextVar = ContextVar(\"HERMES_CRON_AUTO_DELIVER_THREAD_ID\", default=_UNSET)\n+\n _VAR_MAP = {\n     \"HERMES_SESSION_PLATFORM\": _SESSION_PLATFORM,\n     \"HERMES_SESSION_CHAT_ID\": _SESSION_CHAT_ID,","@@ -64,6 +70,9 @@\n     \"HERMES_SESSION_USER_ID\": _SESSION_USER_ID,\n     \"HERMES_SESSION_USER_NAME\": _SESSION_USER_NAME,\n     \"HERMES_SESSION_KEY\": _SESSION_KEY,\n+    \"HERMES_CRON_AUTO_DELIVER_PLATFORM\": _CRON_AUTO_DELIVER_PLATFORM,\n+    \"HERMES_CRON_AUTO_DELIVER_CHAT_ID\": _CRON_AUTO_DELIVER_CHAT_ID,\n+    \"HERMES_CRON_AUTO_DELIVER_THREAD_ID\": _CRON_AUTO_DELIVER_THREAD_ID,\n }\n \n "]},{"file":"hermes_cli/config.py","hunks":["@@ -796,6 +796,11 @@ def _ensure_hermes_home_managed(home: Path):\n         # Wrap delivered cron responses with a header (task name) and footer\n         # (\"The agent cannot see this message\").  Set to false for clean output.\n         \"wrap_response\": True,\n+        # Maximum number of due jobs to run in parallel per tick.\n+        # null/0 = unbounded (limited only by thread count).\n+        # 1 = serial (pre-v0.9 behaviour).\n+        # Also overridable via HERMES_CRON_MAX_PARALLEL env var.\n+        \"max_parallel_jobs\": None,\n     },\n \n     # execute_code settings — controls the tool used for programmatic tool calls."]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -772,9 +772,10 @@ def __init__(self, *args, **kwargs):\n                 pass\n \n             def run_conversation(self, *args, **kwargs):\n-                seen[\"platform\"] = os.getenv(\"HERMES_CRON_AUTO_DELIVER_PLATFORM\")\n-                seen[\"chat_id\"] = os.getenv(\"HERMES_CRON_AUTO_DELIVER_CHAT_ID\")\n-                seen[\"thread_id\"] = os.getenv(\"HERMES_CRON_AUTO_DELIVER_THREAD_ID\")\n+                from gateway.session_context import get_session_env\n+                seen[\"platform\"] = get_session_env(\"HERMES_CRON_AUTO_DELIVER_PLATFORM\") or None\n+                seen[\"chat_id\"] = get_session_env(\"HERMES_CRON_AUTO_DELIVER_CHAT_ID\") or None\n+                seen[\"thread_id\"] = get_session_env(\"HERMES_CRON_AUTO_DELIVER_THREAD_ID\") or None\n                 return {\"final_response\": \"ok\"}\n \n         with patch(\"cron.scheduler._hermes_home\", tmp_path), \\","@@ -1457,3 +1458,125 @@ def test_multiple_media_files_all_delivered(self):\n         self._run_with_loop(adapter, \"123\", media_files, None, {\"id\": \"j3\"})\n         adapter.send_voice.assert_called_once()\n         adapter.send_image_file.assert_called_once()\n+\n+\n+class TestParallelTick:\n+    \"\"\"Verify that tick() runs due jobs concurrently and isolates ContextVars.\"\"\"\n+\n+    @pytest.fixture(autouse=True)\n+    def _isolate_tick_lock(self, tmp_path):\n+        \"\"\"Point the tick file lock at a per-test temp dir to avoid xdist contention.\"\"\"\n+        lock_dir = tmp_path / \"cron\"\n+        lock_dir.mkdir()\n+        with patch(\"cron.scheduler._LOCK_DIR\", lock_dir), \\\n+             patch(\"cron.scheduler._LOCK_FILE\", lock_dir / \".tick.lock\"):\n+            yield\n+\n+    def test_parallel_jobs_run_concurrently(self):\n+        \"\"\"Two jobs launched in the same tick should overlap in time.\"\"\"\n+        import threading\n+        import time\n+\n+        barrier = threading.Barrier(2, timeout=5)\n+        call_order = []\n+\n+        def mock_run_job(job):\n+            \"\"\"Each job hits a barrier — both must be active simultaneously.\"\"\"\n+            call_order.append((\"start\", job[\"id\"]))\n+            barrier.wait()  # blocks until both threads reach here\n+            call_order.append((\"end\", job[\"id\"]))\n+            return (True, \"output\", \"response\", None)\n+\n+        jobs = [\n+            {\"id\": \"job-a\", \"name\": \"a\", \"deliver\": \"local\"},\n+            {\"id\": \"job-b\", \"name\": \"b\", \"deliver\": \"local\"},\n+        ]\n+\n+        with patch(\"cron.scheduler.get_due_jobs\", return_value=jobs), \\\n+             patch(\"cron.scheduler.advance_next_run\"), \\\n+             patch(\"cron.scheduler.run_job\", side_effect=mock_run_job), \\\n+             patch(\"cron.scheduler.save_job_output\", return_value=\"/tmp/out.md\"), \\\n+             patch(\"cron.scheduler._deliver_result\", return_value=None), \\\n+             patch(\"cron.scheduler.mark_job_run\"):\n+            from cron.scheduler import tick\n+            result = tick(verbose=False)\n+\n+        assert result == 2\n+        # Both starts happened before both ends — proof of concurrency\n+        starts = [i for i, (action, _) in enumerate(call_order) if action == \"start\"]\n+        ends = [i for i, (action, _) in enumerate(call_order) if action == \"end\"]\n+        assert len(starts) == 2\n+        assert len(ends) == 2\n+        assert max(starts) < min(ends), f\"Jobs not concurrent: {call_order}\"\n+\n+    def test_parallel_jobs_isolated_contextvars(self):\n+        \"\"\"Each job's ContextVars must be isolated — no cross-contamination.\"\"\"\n+        from gateway.session_context import get_session_env\n+        seen = {}\n+\n+        def mock_run_job(job):\n+            origin = job.get(\"origin\", {})\n+            # run_job sets ContextVars — verify each job sees its own\n+            from gateway.session_context import set_session_vars, clear_session_vars\n+            tokens = [redacted:secret-assignment]\n+                platform=origin.get(\"platform\", \"\"),\n+                chat_id=str(origin.get(\"chat_id\", \"\")),\n+            )\n+            import time\n+            time.sleep(0.05)  # give other thread time to set its vars\n+            platform = get_session_env(\"HERMES_SESSION_PLATFORM\")\n+            chat_id = get_session_env(\"HERMES_SESSION_CHAT_ID\")\n+            seen[job[\"id\"]] = {\"platform\": platform, \"chat_id\": chat_id}\n+            clear_session_vars(tokens)\n+            return (True, \"output\", \"response\", None)\n+\n+        jobs = [\n+            {\"id\": \"tg-job\", \"name\": \"tg\", \"deliver\": \"local\",\n+             \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"111\"}},\n+            {\"id\": \"dc-job\", \"name\": \"dc\", \"deliver\": \"local\",\n+             \"origin\": {\"platform\": \"discord\", \"chat_id\": \"222\"}},\n+        ]\n+\n+        with patch(\"cron.scheduler.get_due_jobs\", return_value=jobs), \\\n+             patch(\"cron.scheduler.advance_next_run\"), \\\n+             patch(\"cron.scheduler.run_job\", side_effect=mock_run_job), \\\n+             patch(\"cron.scheduler.save_job_output\", return_value=\"/tmp/out.md\"), \\\n+             patch(\"cron.scheduler._deliver_result\", return_value=None), \\\n+             patch(\"cron.scheduler.mark_job_run\"):\n+            from cron.scheduler import tick\n+            tick(verbose=False)\n+\n+        assert seen[\"tg-job\"] == {\"platform\": \"telegram\", \"chat_id\": \"111\"}\n+        assert seen[\"dc-job\"] == {\"platform\": \"discord\", \"chat_id\": \"222\"}\n+\n+    def test_max_parallel_env_var(self, monkeypatch):\n+        \"\"\"HERMES_CRON_MAX_PARALLEL=1 should restore serial behaviour.\"\"\"\n+        monkeypatch.setenv(\"HERMES_CRON_MAX_PARALLEL\", \"1\")\n+        call_times = []\n+\n+        def mock_run_job(job):\n+            import time\n+            call_times.append((\"start\", job[\"id\"], time.monotonic()))\n+            time.sleep(0.05)\n+            call_times.append((\"end\", job[\"id\"], time.monotonic()))\n+            return (True, \"output\", \"response\", None)\n+\n+        jobs = [\n+            {\"id\": \"s1\", \"name\": \"s1\", \"deliver\": \"local\"},\n+            {\"id\": \"s2\", \"name\": \"s2\", \"deliver\": \"local\"},\n+        ]\n+\n+        with patch(\"cron.scheduler.get_due_jobs\", return_value=jobs), \\\n+             patch(\"cron.scheduler.advance_next_run\"), \\\n+             patch(\"cron.scheduler.run_job\", side_effect=mock_run_job), \\\n+             patch(\"cron.scheduler.save_job_output\", return_value=\"/tmp/out.md\"), \\\n+             patch(\"cron.scheduler._deliver_result\", return_value=None), \\\n+             patch(\"cron.scheduler.mark_job_run\"):\n+            from cron.scheduler import tick\n+            result = tick(verbose=False)\n+\n+        assert result == 2\n+        # With max_workers=1, second job starts after first ends\n+        end_s1 = [t for action, jid, t in call_times if action == \"end\" and jid == \"s1\"][0]\n+        start_s2 = [t for action, jid, t in call_times if action == \"start\" and jid == \"s2\"][0]\n+        assert start_s2 >= end_s1, \"Jobs ran concurrently despite max_parallel=1\""]},{"file":"tools/send_message_tool.py","hunks":["@@ -359,11 +359,12 @@ def _describe_media_for_mirror(media_files):\n \n def _get_cron_auto_delivery_target():\n     \"\"\"Return the cron scheduler's auto-delivery target for the current run, if any.\"\"\"\n-    platform = os.getenv(\"HERMES_CRON_AUTO_DELIVER_PLATFORM\", \"\").strip().lower()\n-    chat_id = os.getenv(\"HERMES_CRON_AUTO_DELIVER_CHAT_ID\", \"\").strip()\n+    from gateway.session_context import get_session_env\n+    platform = get_session_env(\"HERMES_CRON_AUTO_DELIVER_PLATFORM\", \"\").strip().lower()\n+    chat_id = get_session_env(\"HERMES_CRON_AUTO_DELIVER_CHAT_ID\", \"\").strip()\n     if not platform or not chat_id:\n         return None\n-    thread_id = os.getenv(\"HERMES_CRON_AUTO_DELIVER_THREAD_ID\", \"\").strip() or None\n+    thread_id = get_session_env(\"HERMES_CRON_AUTO_DELIVER_THREAD_ID\", \"\").strip() or None\n     return {\n         \"platform\": platform,\n         \"chat_id\": chat_id,\n"]}]},{"kind":"pr","number":29854,"title":"fix(cron): restore cron session environment after jobs","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"9c4b5aff69ac045d65c5e3aeb6181cc81376843d","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35515,"title":"fix(cron): restore cron session env after jobs","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#43549","number":43549},{"kind":"pr","value":"#29854","number":29854}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":37969,"title":"fix(cron): isolate gateway approvals from environment pollution","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#77022","number":77022},{"kind":"pr","value":"#43370","number":43370}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":43370,"title":"Fix cron session context isolation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#77022","number":77022}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":43549,"title":"fix(cron): restore HERMES_CRON_SESSION after jobs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#77022","number":77022}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":56784,"title":"fix(approval): let gateway session indicators override process-wide cron flag","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":56788,"title":"fix(approval): ignore leaked HERMES_CRON_SESSION in interactive sessions (#56771)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56771","number":56771},{"kind":"pr","value":"#56784","number":56784},{"kind":"pr","value":"#56796","number":56796}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":56796,"title":"fix(approval): use per-job ContextVar for cron-session flag instead of leaking env var (#56771)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#56771","number":56771},{"kind":"pr","value":"#57736","number":57736},{"kind":"pr","value":"#60350","number":60350},{"kind":"pr","value":"#56796","number":56796},{"kind":"pr","value":"#57124","number":57124},{"kind":"pr","value":"#59242","number":59242},{"kind":"pr","value":"#56784","number":56784},{"kind":"pr","value":"#57169","number":57169},{"kind":"pr","value":"#36004","number":36004},{"kind":"pr","value":"#56796","number":56796},{"kind":"pr","value":"#56796","number":56796}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":57124,"title":"fix(cron): clean up HERMES_CRON_SESSION env var after each job run (#56771)","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":58663,"title":"fix(security): scope the cron-session approval marker to the job context","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a168b599b8","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":59242,"title":"fix(cron): clean up HERMES_CRON_SESSION env var after each job run (#59236)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57124","number":57124}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":59719,"title":"fix(cron): scope approval marker to cron execution context (#58662)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#58663","number":58663},{"kind":"pr","value":"#58662","number":58662},{"kind":"pr","value":"#58662","number":58662},{"kind":"pr","value":"#57169","number":57169}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":60077,"title":"fix(approval): gate unattended VCS mutations","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"c60b2bad5","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":60386,"title":"fix(cron): clean up HERMES_CRON_SESSION env var after each job to prevent process-wide leak into gateway sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57124","number":57124},{"kind":"pr","value":"#56771","number":56771},{"kind":"pr","value":"#59242","number":59242},{"kind":"pr","value":"#57124","number":57124},{"kind":"pr","value":"#57124","number":57124},{"kind":"pr","value":"#56796","number":56796},{"kind":"pr","value":"#56784","number":56784}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":60611,"title":"fix(cron): clean up HERMES_CRON_SESSION env var after job completes (#60350)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#57124","number":57124},{"kind":"pr","value":"#60350","number":60350},{"kind":"pr","value":"#56771","number":56771},{"kind":"pr","value":"#60386","number":60386},{"kind":"pr","value":"#57124","number":57124},{"kind":"pr","value":"#57124","number":57124}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":77022,"title":"fix(cron): scope cron approval context per session","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/server.py","cron/scheduler.py","gateway/platforms/api_server.py","gateway/run.py","gateway/session_context.py","tests/cron/conftest.py","tests/cron/test_scheduler_cron_session_isolation.py","tests/gateway/test_session_env.py","tests/tools/test_cron_approval_mode.py","tests/tools/test_execute_code_approval_cluster.py","tests/tools/test_request_tool_approval.py","tools/approval.py","tools/environments/base.py","tui_gateway/server.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}