# NousResearch/hermes-agent · HERMES_CRON_SESSION env var leaking into interactive sessions

## Open now

- 6 issues, 8 PRs open on GitHub.
- #56771: \[Bug\]: \`execute_code\` blocked in interactive Telegram gateway session because \`HERMES_CRON_SESSION\` env var leaks from cron scheduler into user's shell
- #57736: HERMES_CRON_SESSION set process-globally: interactive gateway sessions inherit cron approval policy after any cron runs
- #59236: Follow up: avoid process-wide cron marker leaking into later interactive gateway approvals
- #60350: \[Bug\] execute_code blocked in WeCom gateway session — HERMES_CRON_SESSION env var leaks from in-process cron ticker (corroborating #56771)
- #60997: fix: HERMES_CRON_SESSION env var leaks into global process context, blocking execute_code in user sessions
- #68286: \[Bug\]: 2 commands one by one mixed actions together- halucinated
- PR #29854: fix(cron): restore cron session environment after jobs
- PR #35515: fix(cron): restore cron session env after jobs
- PR #56784: fix(approval): let gateway session indicators override process-wide cron flag
- PR #56796: fix(approval): use per-job ContextVar for cron-session flag instead of leaking env var (#56771)
- PR #57124: fix(cron): clean up HERMES_CRON_SESSION env var after each job run (#56771)
- PR #59719: fix(cron): scope approval marker to cron execution context (#58662)
- PR #60077: fix(approval): gate unattended VCS mutations
- PR #60611: fix(cron): clean up HERMES_CRON_SESSION env var after job completes (#60350)

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.

## TL;DR

- 31 member(s): 6 open issue(s), 6 closed issue(s), 8 open PR(s), 2 merged PR(s), 9 PR(s) closed without merging.
- A fix was merged to main in PR #13021. Verify it addresses your case.
- **Recommended:** read PR #13021 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-77022`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.07`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #56771: \[Bug\]: \`execute_code\` blocked in interactive Telegram gateway session because \`HERMES_CRON_SESSION\` env var leaks from cron scheduler into user's shell
- #57736: HERMES_CRON_SESSION set process-globally: interactive gateway sessions inherit cron approval policy after any cron runs
- #59236: Follow up: avoid process-wide cron marker leaking into later interactive gateway approvals
- #60350: \[Bug\] execute_code blocked in WeCom gateway session — HERMES_CRON_SESSION env var leaks from in-process cron ticker (corroborating #56771)
- #60997: fix: HERMES_CRON_SESSION env var leaks into global process context, blocking execute_code in user sessions
- #68286: \[Bug\]: 2 commands one by one mixed actions together- halucinated

## Closed / duplicate issues

- #4262: Bug: Cron job environment variables leak between sequential jobs
- #8866: Cron delivery target can be hijacked by current home channel even when deliver is explicitly set
- #10769: \[bug\]: Cross-talk and Identity Leakage in Cron Job Delivery due to Global Environment Variable Usage
- #37968: fix(cron): isolate gateway approvals from environment pollution
- #58662: \[Bug\]: in-process cron ticker leaks its approval marker into interactive gateway sessions
- #62905: Cron tool calls can route to interactive approval (pending_approval) after a gateway restart, yielding silent no-op job output

## Prior & competing PRs

- **#13021: fix(cron): run due jobs in parallel to prevent serial tick starvation**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `cron/jobs.py`

    ```diff
    @@ -9,6 +9,7 @@
     import json
     import logging
     import tempfile
    +import threading
     import os
     import re
     import uuid
    ```

    ```diff
    @@ -34,6 +35,11 @@
     HERMES_DIR = get_hermes_home().resolve()
     CRON_DIR = HERMES_DIR / "cron"
     JOBS_FILE = CRON_DIR / "jobs.json"
    +
    +# In-process lock protecting load_jobs→modify→save_jobs cycles.
    +# Required when tick() runs jobs in parallel threads — without this,
    +# concurrent mark_job_run / advance_next_run calls can clobber each other.
    +_jobs_file_lock = threading.Lock()
     OUTPUT_DIR = CRON_DIR / "output"
     ONESHOT_GRACE_SECONDS = 120
     
    ```

    ```diff
    @@ -594,43 +600,44 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,
         ``delivery_error`` is tracked separately from the agent error — a job
         can succeed (agent produced output) but fail delivery (platform down).
         """
    -    jobs = load_jobs()
    -    for i, job in enumerate(jobs):
    -        if job["id"] == job_id:
    -            now = _hermes_now().isoformat()
    -            job["last_run_at"] = now
    -            job["last_status"] = "ok" if success else "error"
    -            job["last_error"] = error if not success else None
    -            # Track delivery failures separately — cleared on successful delivery
    -            job["last_delivery_error"] = delivery_error
    -            
    -            # Increment completed count
    -            if job.get("repeat"):
    -                job["repeat"]["completed"] = job["repeat"].get("completed", 0) + 1
    +    with _jobs_file_lock:
    +        jobs = load_jobs()
    +        for i, job in enumerate(jobs):
    +            if job["id"] == job_id:
    +                now = _hermes_now().isoformat()
    +                job["last_run_at"] = now
    +                job["last_status"] = "ok" if success else "error"
    +                job["last_error"] = error if not success else None
    +                # Track delivery failures separately — cleared on successful delivery
    +                job["last_delivery_error"] = delivery_error
                     
    -                # Check if we've hit the repeat limit
    -                times = job["repeat"].get("times")
    -                completed = job["repeat"]["completed"]
    -                if times is not None and times > 0 and completed >= times:
    -                    # Remove the job (limit reached)
    -                    jobs.pop(i)
    -                    save_jobs(jobs)
    -                    return
    -            
    -            # Compute next run
    -            job["next_run_at"] = compute_next_run(job["schedule"], now)
    +                # Increment completed count
    +                if job.get("repeat"):
    +                    job["repeat"]["completed"] = job["repeat"].get("completed", 0) + 1
    +                    
    +                    # Check if we've hit the repeat limit
    +                    times = job["repeat"].get("times")
    +                    completed = job["repeat"]["completed"]
    +                    if times is not None and times > 0 and completed >= times:
    +                        # Remove the job (limit reached)
    +                        jobs.pop(i)
    +                        save_jobs(jobs)
    +                        return
    +                
    +                # Compute next run
    +                job["next_run_at"] = compute_next_run(job["schedule"], now)
     
    -            # If no next run (one-shot completed), disable
    -            if job["next_run_at"] is None:
    -                job["enabled"] = False
    -                job["state"] = "completed"
    -            elif job.get("state") != "paused":
    -                job["state"] = "scheduled"
    +                # If no next run (one-shot completed), disable
    +                if job["next_run_at"] is None:
    +                    job["enabled"] = False
    +                    job["state"] = "completed"
    +                elif job.get("state") != "paused":
    +                    job["state"] = "scheduled"
     
    -            save_jobs(jobs)
    -            return
    +                save_jobs(jobs)
    +                return
     
    -    logger.warning("mark_job_run: job_id %s not found, skipping save", job_id)
    +        logger.warning("mark_job_run: job_id %s not found, skipping save", job_id)
     
     
     def advance_next_run(job_id: str) -> bool:
    ```

    ```diff
    @@ -645,20 +652,21 @@ def advance_next_run(job_id: str) -> bool:
     
         Returns True if next_run_at was advanced, False otherwise.
         """
    -    jobs = load_jobs()
    -    for job in jobs:
    -        if job["id"] == job_id:
    -            kind = job.get("schedule", {}).get("kind")
    -            if kind not in ("cron", "interval"):
    +    with _jobs_file_lock:
    +        jobs = load_jobs()
    +        for job in jobs:
    +            if job["id"] == job_id:
    +                kind = job.get("schedule", {}).get("kind")
    +                if kind not in ("cron", "interval"):
    +                    return False
    +                now = _hermes_now().isoformat()
    +                new_next = compute_next_run(job["schedule"], now)
    +                if new_next and new_next != job.get("next_run_at"):
    +                    job["next_run_at"] = new_next
    +                    save_jobs(jobs)
    +                    return True
                     return False
    -            now = _hermes_now().isoformat()
    -            new_next = compute_next_run(job["schedule"], now)
    -            if new_next and new_next != job.get("next_run_at"):
    -                job["next_run_at"] = new_next
    -                save_jobs(jobs)
    -                return True
    -            return False
    -    return False
    +        return False
     
     
     def get_due_jobs() -> List[Dict[str, Any]]:
    ```
  - `cron/scheduler.py`

    ```diff
    @@ -747,14 +747,17 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
         # scheduler process — every job this process runs is a cron job.
         os.environ["HERMES_CRON_SESSION"] = "1"
     
    +    # Use ContextVars for per-job session/delivery state so parallel jobs
    +    # don't clobber each other's targets (os.environ is process-global).
    +    from gateway.session_context import set_session_vars, clear_session_vars, _VAR_MAP
    +
    +    _ctx_tokens = [redacted:secret-assignment]
    +        platform=origin["platform"] if origin else "",
    +        chat_id=str(origin["chat_id"]) if origin else "",
    +        chat_name=origin.get("chat_name", "") if origin else "",
    +    )
    +
         try:
    -        # Inject origin context so the agent's send_message tool knows the chat.
    -        # Must be INSIDE the try block so the finally cleanup always runs.
    -        if origin:
    -            os.environ["HERMES_SESSION_PLATFORM"] = origin["platform"]
    -            os.environ["HERMES_SESSION_CHAT_ID"] = str(origin["chat_id"])
    -            if origin.get("chat_name"):
    -                os.environ["HERMES_SESSION_CHAT_NAME"] = origin["chat_name"]
             # Re-read .env and config.yaml fresh every run so provider/key
             # changes take effect without a gateway restart.
             from dotenv import load_dotenv
    ```

    ```diff
    @@ -765,10 +768,10 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
     
             delivery_target = _resolve_delivery_target(job)
             if delivery_target:
    -            os.environ["HERMES_CRON_AUTO_DELIVER_PLATFORM"] = delivery_target["platform"]
    -            os.environ["HERMES_CRON_AUTO_DELIVER_CHAT_ID"] = str(delivery_target["chat_id"])
    +            _VAR_MAP["HERMES_CRON_AUTO_DELIVER_PLATFORM"].set(delivery_target["platform"])
    +            _VAR_MAP["HERMES_CRON_AUTO_DELIVER_CHAT_ID"].set(str(delivery_target["chat_id"]))
                 if delivery_target.get("thread_id") is not None:
    -                os.environ["HERMES_CRON_AUTO_DELIVER_THREAD_ID"] = str(delivery_target["thread_id"])
    +                _VAR_MAP["HERMES_CRON_AUTO_DELIVER_THREAD_ID"].set(str(delivery_target["thread_id"]))
     
             model = job.get("model") or os.getenv("HERMES_MODEL") or ""
     
    ```

    ```diff
    @@ -1012,16 +1015,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
             return False, output, "", error_msg
     
         finally:
    -        # Clean up injected env vars so they don't leak to other jobs
    -        for key in (
    -            "HERMES_SESSION_PLATFORM",
    -            "HERMES_SESSION_CHAT_ID",
    -            "HERMES_SESSION_CHAT_NAME",
    -            "HERMES_CRON_AUTO_DELIVER_PLATFORM",
    -            "HERMES_CRON_AUTO_DELIVER_CHAT_ID",
    -            "HERMES_CRON_AUTO_DELIVER_THREAD_ID",
    -        ):
    -            os.environ.pop(key, None)
    +        # Clean up ContextVar session/delivery state for this job.
    +        clear_session_vars(_ctx_tokens)
             if _session_db:
                 try:
                     _session_db.end_session(_cron_session_id, "cron_complete")
    ```

    ```diff
    @@ -1074,15 +1069,42 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
             if verbose:
                 logger.info("%s - %s job(s) due", _hermes_now().strftime('%H:%M:%S'), len(due_jobs))
     
    -        executed = 0
    +        # Advance next_run_at for all recurring jobs FIRST, under the file lock,
    +        # before any execution begins.  This preserves at-most-once semantics.
             for job in due_jobs:
    +            advance_next_run(job["id"])
    +
    +        # Resolve max parallel workers: env var > config.yaml > unbounded.
    +        # Set HERMES_CRON_MAX_PARALLEL=1 to restore old serial behaviour.
    +        _max_workers: Optional[int] = None
    +        try:
    +            _env_par = os.getenv("HERMES_CRON_MAX_PARALLEL", "").strip()
    +            if _env_par:
    +                _max_workers = int(_env_par) or None
    +        except (ValueError, TypeError):
    +            logger.warning("Invalid HERMES_CRON_MAX_PARALLEL value; defaulting to unbounded")
    +        if _max_workers is None:
                 try:
    -                # For recurring jobs (cron/interval), advance next_run_at to the
    -                # next future occurrence BEFORE execution.  This way, if the
    -                # process crashes mid-run, the job won't re-fire on restart.
    -                # One-shot jobs are left alone so they can retry on restart.
    -                advance_next_run(job["id"])
    +                from hermes_cli.config import load_config
    +                _ucfg = load_config() or {}
    +                _cfg_par = (
    +                    _ucfg.get("cron", {}) if isinstance(_ucfg, dict) else {}
    +                ).get("max_parallel_jobs")
    +                if _cfg_par is not None:
    +                    _max_workers = int(_cfg_par) or None
    +            except Exception:
    +                pass
     
    +        if verbose:
    +            logger.info(
    +                "Running %d job(s) in parallel (max_workers=%s)",
    +                len(due_jobs),
    +                _max_workers if _max_workers else "unbounded",
    +            )
    +
    +        def _process_job(job: dict) -> bool:
    +            """Run one due job end-to-end: execute, save, deliver, mark."""
    +            try:
                     success, output, final_response, error = run_job(job)
     
                     output_file = save_job_output(job["id"], output)
    ```

    ```diff
    @@ -1114,13 +1136,23 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
                         error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"
     
                     mark_job_run(job["id"], success, error, delivery_error=delivery_error)
    -                executed += 1
    +                return True
     
                 except Exception as e:
                     logger.error("Error processing job %s: %s", job['id'], e)
                     mark_job_run(job["id"], False, str(e))
    -
    -        return executed
    +                return False
    +
    +        # Run all due jobs concurrently, each in its own ContextVar copy
    +        # so session/delivery state stays isolated per-thread.
    +        with concurrent.futures.ThreadPoolExecutor(max_workers=_max_workers) as _tick_pool:
    +            _futures = []
    +            for job in due_jobs:
    +                _ctx = contextvars.copy_context()
    +                _futures.append(_tick_pool.submit(_ctx.run, _process_job, job))
    +            _results = [f.result() for f in _futures]
    +
    +        return sum(_results)
         finally:
             if fcntl:
                 fcntl.flock(lock_fd, fcntl.LOCK_UN)
    ```
  - `gateway/session_context.py`

    ```diff
    @@ -56,6 +56,12 @@
     _SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET)
     _SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET)
     
    +# Cron auto-delivery vars — set per-job in run_job() so concurrent jobs
    +# don't clobber each other's delivery targets.
    +_CRON_AUTO_DELIVER_PLATFORM: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_PLATFORM", default=_UNSET)
    +_CRON_AUTO_DELIVER_CHAT_ID: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_CHAT_ID", default=_UNSET)
    +_CRON_AUTO_DELIVER_THREAD_ID: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_THREAD_ID", default=_UNSET)
    +
     _VAR_MAP = {
         "HERMES_SESSION_PLATFORM": _SESSION_PLATFORM,
         "HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID,
    ```

    ```diff
    @@ -64,6 +70,9 @@
         "HERMES_SESSION_USER_ID": _SESSION_USER_ID,
         "HERMES_SESSION_USER_NAME": _SESSION_USER_NAME,
         "HERMES_SESSION_KEY": _SESSION_KEY,
    +    "HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM,
    +    "HERMES_CRON_AUTO_DELIVER_CHAT_ID": _CRON_AUTO_DELIVER_CHAT_ID,
    +    "HERMES_CRON_AUTO_DELIVER_THREAD_ID": _CRON_AUTO_DELIVER_THREAD_ID,
     }
     
     
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -796,6 +796,11 @@ def _ensure_hermes_home_managed(home: Path):
             # Wrap delivered cron responses with a header (task name) and footer
             # ("The agent cannot see this message").  Set to false for clean output.
             "wrap_response": True,
    +        # Maximum number of due jobs to run in parallel per tick.
    +        # null/0 = unbounded (limited only by thread count).
    +        # 1 = serial (pre-v0.9 behaviour).
    +        # Also overridable via HERMES_CRON_MAX_PARALLEL env var.
    +        "max_parallel_jobs": None,
         },
     
         # execute_code settings — controls the tool used for programmatic tool calls.
    ```
  - `tests/cron/test_scheduler.py`

    ```diff
    @@ -772,9 +772,10 @@ def __init__(self, *args, **kwargs):
                     pass
     
                 def run_conversation(self, *args, **kwargs):
    -                seen["platform"] = os.getenv("HERMES_CRON_AUTO_DELIVER_PLATFORM")
    -                seen["chat_id"] = os.getenv("HERMES_CRON_AUTO_DELIVER_CHAT_ID")
    -                seen["thread_id"] = os.getenv("HERMES_CRON_AUTO_DELIVER_THREAD_ID")
    +                from gateway.session_context import get_session_env
    +                seen["platform"] = get_session_env("HERMES_CRON_AUTO_DELIVER_PLATFORM") or None
    +                seen["chat_id"] = get_session_env("HERMES_CRON_AUTO_DELIVER_CHAT_ID") or None
    +                seen["thread_id"] = get_session_env("HERMES_CRON_AUTO_DELIVER_THREAD_ID") or None
                     return {"final_response": "ok"}
     
             with patch("cron.scheduler._hermes_home", tmp_path), \
    ```

    ```diff
    @@ -1457,3 +1458,125 @@ def test_multiple_media_files_all_delivered(self):
             self._run_with_loop(adapter, "123", media_files, None, {"id": "j3"})
             adapter.send_voice.assert_called_once()
             adapter.send_image_file.assert_called_once()
    +
    +
    +class TestParallelTick:
    +    """Verify that tick() runs due jobs concurrently and isolates ContextVars."""
    +
    +    @pytest.fixture(autouse=True)
    +    def _isolate_tick_lock(self, tmp_path):
    +        """Point the tick file lock at a per-test temp dir to avoid xdist contention."""
    +        lock_dir = tmp_path / "cron"
    +        lock_dir.mkdir()
    +        with patch("cron.scheduler._LOCK_DIR", lock_dir), \
    +             patch("cron.scheduler._LOCK_FILE", lock_dir / ".tick.lock"):
    +            yield
    +
    +    def test_parallel_jobs_run_concurrently(self):
    +        """Two jobs launched in the same tick should overlap in time."""
    +        import threading
    +        import time
    +
    +        barrier = threading.Barrier(2, timeout=5)
    +        call_order = []
    +
    +        def mock_run_job(job):
    +            """Each job hits a barrier — both must be active simultaneously."""
    +            call_order.append(("start", job["id"]))
    +            barrier.wait()  # blocks until both threads reach here
    +            call_order.append(("end", job["id"]))
    +            return (True, "output", "response", None)
    +
    +        jobs = [
    +            {"id": "job-a", "name": "a", "deliver": "local"},
    +            {"id": "job-b", "name": "b", "deliver": "local"},
    +        ]
    +
    +        with patch("cron.scheduler.get_due_jobs", return_value=jobs), \
    +             patch("cron.scheduler.advance_next_run"), \
    +             patch("cron.scheduler.run_job", side_effect=mock_run_job), \
    +             patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
    +             patch("cron.scheduler._deliver_result", return_value=None), \
    +             patch("cron.scheduler.mark_job_run"):
    +            from cron.scheduler import tick
    +            result = tick(verbose=False)
    +
    +        assert result == 2
    +        # Both starts happened before both ends — proof of concurrency
    +        starts = [i for i, (action, _) in enumerate(call_order) if action == "start"]
    +        ends = [i for i, (action, _) in enumerate(call_order) if action == "end"]
    +        assert len(starts) == 2
    +        assert len(ends) == 2
    +        assert max(starts) < min(ends), f"Jobs not concurrent: {call_order}"
    +
    +    def test_parallel_jobs_isolated_contextvars(self):
    +        """Each job's ContextVars must be isolated — no cross-contamination."""
    +        from gateway.session_context import get_session_env
    +        seen = {}
    +
    +        def mock_run_job(job):
    +            origin = job.get("origin", {})
    +            # run_job sets ContextVars — verify each job sees its own
    +            from gateway.session_context import set_session_vars, clear_session_vars
    +            tokens = [redacted:secret-assignment]
    +                platform=origin.get("platform", ""),
    +                chat_id=str(origin.get("chat_id", "")),
    +            )
    +            import time
    +            time.sleep(0.05)  # give other thread time to set its vars
    +            platform = get_session_env("HERMES_SESSION_PLATFORM")
    +            chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
    +            seen[job["id"]] = {"platform": platform, "chat_id": chat_id}
    +            clear_session_vars(tokens)
    +            return (True, "output", "response", None)
    +
    +        jobs = [
    +            {"id": "tg-job", "name": "tg", "deliver": "local",
    +             "origin": {"platform": "telegram", "chat_id": "111"}},
    +            {"id": "dc-job", "name": "dc", "deliver": "local",
    +             "origin": {"platform": "discord", "chat_id": "222"}},
    +        ]
    +
    +        with patch("cron.scheduler.get_due_jobs", return_value=jobs), \
    +             patch("cron.scheduler.advance_next_run"), \
    +             patch("cron.scheduler.run_job", side_effect=mock_run_job), \
    +             patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
    +             patch("cron.scheduler._deliver_result", return_value=None), \
    +             patch("cron.scheduler.mark_job_run"):
    +            from cron.scheduler import tick
    +            tick(verbose=False)
    +
    +        assert seen["tg-job"] == {"platform": "telegram", "chat_id": "111"}
    +        assert seen["dc-job"] == {"platform": "discord", "chat_id": "222"}
    +
    +    def test_max_parallel_env_var(self, monkeypatch):
    +        """HERMES_CRON_MAX_PARALLEL=1 should restore serial behaviour."""
    +        monkeypatch.setenv("HERMES_CRON_MAX_PARALLEL", "1")
    +        call_times = []
    +
    +        def mock_run_job(job):
    +            import time
    +            call_times.append(("start", job["id"], time.monotonic()))
    +            time.sleep(0.05)
    +            call_times.append(("end", job["id"], time.monotonic()))
    +            return (True, "output", "response", None)
    +
    +        jobs = [
    +            {"id": "s1", "name": "s1", "deliver": "local"},
    +            {"id": "s2", "name": "s2", "deliver": "local"},
    +        ]
    +
    +        with patch("cron.scheduler.get_due_jobs", return_value=jobs), \
    +             patch("cron.scheduler.advance_next_run"), \
    +             patch("cron.scheduler.run_job", side_effect=mock_run_job), \
    +             patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
    +             patch("cron.scheduler._deliver_result", return_value=None), \
    +             patch("cron.scheduler.mark_job_run"):
    +            from cron.scheduler import tick
    +            result = tick(verbose=False)
    +
    +        assert result == 2
    +        # With max_workers=1, second job starts after first ends
    +        end_s1 = [t for action, jid, t in call_times if action == "end" and jid == "s1"][0]
    +        start_s2 = [t for action, jid, t in call_times if action == "start" and jid == "s2"][0]
    +        assert start_s2 >= end_s1, "Jobs ran concurrently despite max_parallel=1"
    ```
  - `tools/send_message_tool.py`

    ```diff
    @@ -359,11 +359,12 @@ def _describe_media_for_mirror(media_files):
     
     def _get_cron_auto_delivery_target():
         """Return the cron scheduler's auto-delivery target for the current run, if any."""
    -    platform = os.getenv("HERMES_CRON_AUTO_DELIVER_PLATFORM", "").strip().lower()
    -    chat_id = os.getenv("HERMES_CRON_AUTO_DELIVER_CHAT_ID", "").strip()
    +    from gateway.session_context import get_session_env
    +    platform = get_session_env("HERMES_CRON_AUTO_DELIVER_PLATFORM", "").strip().lower()
    +    chat_id = get_session_env("HERMES_CRON_AUTO_DELIVER_CHAT_ID", "").strip()
         if not platform or not chat_id:
             return None
    -    thread_id = os.getenv("HERMES_CRON_AUTO_DELIVER_THREAD_ID", "").strip() or None
    +    thread_id = get_session_env("HERMES_CRON_AUTO_DELIVER_THREAD_ID", "").strip() or None
         return {
             "platform": platform,
             "chat_id": chat_id,
    ```
- **#56788: fix(approval): ignore leaked HERMES_CRON_SESSION in interactive sessions (#56771)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#56771`, `#56784`, `#56796`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#60386: fix(cron): clean up HERMES_CRON_SESSION env var after each job to prevent process-wide leak into gateway sessions**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#57124`, `#56771`, `#59242`, `#57124`, `#57124`, `#56796`, `#56784`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#77022: fix(cron): scope cron approval context per session**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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`
- **#10795: fix(cron): eliminate os.environ race in concurrent job delivery**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `c86915024`, `#13021`
- **#10808: Isolate cron delivery context per scheduled job**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `c86915024`, `#13021`, `#10769`, `#10769`
- **#29854: fix(cron): restore cron session environment after jobs**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `9c4b5aff69ac045d65c5e3aeb6181cc81376843d`
  - files: `cron/scheduler.py`, `tests/cron/test_cron_approval_context.py`, `tools/approval.py`
- **#35515: fix(cron): restore cron session env after jobs**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#43549`, `#29854`
  - files: `cron/scheduler.py`, `tests/cron/test_scheduler.py`
- **#37969: fix(cron): isolate gateway approvals from environment pollution**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#77022`, `#43370`
- **#43370: Fix cron session context isolation**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#77022`
- **#43549: fix(cron): restore HERMES_CRON_SESSION after jobs**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#77022`
- **#56784: fix(approval): let gateway session indicators override process-wide cron flag**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `tests/tools/test_approval.py`, `tests/tools/test_cron_approval_mode.py`, `tools/approval.py`
- **#56796: fix(approval): use per-job ContextVar for cron-session flag instead of leaking env var (#56771)**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#56771`, `#57736`, `#60350`, `#56796`, `#57124`, `#59242`, `#56784`, `#57169`, `#36004`, `#56796`, `#56796`
  - files: `cron/scheduler.py`, `gateway/session_context.py`, `tests/tools/test_cron_approval_mode.py`, `tests/tools/test_cron_session_leak.py`, `tests/tools/test_execute_code_approval_cluster.py`, `tools/approval.py`
- **#57124: fix(cron): clean up HERMES_CRON_SESSION env var after each job run (#56771)**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `cron/scheduler.py`
- **#58663: fix(security): scope the cron-session approval marker to the job context**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `a168b599b8`
- **#59242: fix(cron): clean up HERMES_CRON_SESSION env var after each job run (#59236)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#57124`
- **#59719: fix(cron): scope approval marker to cron execution context (#58662)**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#58663`, `#58662`, `#58662`, `#57169`
  - files: `cron/scheduler.py`, `gateway/session_context.py`, `tests/cron/test_approval_marker_isolation.py`, `tools/approval.py`
- **#60077: fix(approval): gate unattended VCS mutations**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `c60b2bad5`
  - files: `gateway/kanban_watchers.py`, `gateway/run.py`, `gateway/session_context.py`, `skills/research/research-paper-writing/references/experiment-patterns.md`, `tests/gateway/test_42039_duplicate_user_message.py`, `tests/gateway/test_approve_deny_commands.py`, `tests/gateway/test_first_turn_session_meta_rebaseline.py`, `tests/gateway/test_footer_command_mid_run.py`, `tests/gateway/test_gateway_command_dispatch_minimal.py`, `tests/gateway/test_gateway_silence_tokens.py`, `tests/gateway/test_incomplete_gateway_turns.py`, `tests/gateway/test_priority_path_compression_demotion_56391.py`, `tests/gateway/test_queue_command.py`, `tests/gateway/test_reload_skills_command.py`, `tests/gateway/test_running_agent_session_toggles.py`, `tests/gateway/test_session_context_inheritance.py`, `tests/gateway/test_session_env.py`, `tests/gateway/test_session_hygiene.py`, `tests/gateway/test_slash_access_dispatch.py`, `tests/gateway/test_stacked_skill_platform_disabled.py` (+8 more)
- **#60611: fix(cron): clean up HERMES_CRON_SESSION env var after job completes (#60350)**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#57124`, `#60350`, `#56771`, `#60386`, `#57124`, `#57124`
  - files: `cron/scheduler.py`

## Fix status

- Merged to main in PR #13021 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-08-02`.
- Subsystem: `agent`
