From 9a48232564a5f3fb98a815b7c5002c6fb3f966c1 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Tue, 16 Jun 2026 09:16:08 +0800 Subject: [PATCH] Fix cron dashboard trigger-now execution --- hermes_cli/web_server.py | 28 +++++++- .../test_web_server_cron_profiles.py | 65 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0e77b3d7a2531..e5ed35edb7274 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6780,6 +6780,30 @@ def _call_cron_for_profile(profile: Optional[str], func_name: str, *args, **kwar return result +def _call_cron_scheduler_for_profile(profile: Optional[str], func_name: str, *args, **kwargs): + """Run cron.scheduler helpers against the selected profile home.""" + profile_name, home = _cron_profile_home(profile) + with _CRON_PROFILE_LOCK: + from cron import jobs as cron_jobs + from cron import scheduler as cron_scheduler + + old_cron_dir = cron_jobs.CRON_DIR + old_jobs_file = cron_jobs.JOBS_FILE + old_output_dir = cron_jobs.OUTPUT_DIR + old_scheduler_home = cron_scheduler._hermes_home + cron_jobs.CRON_DIR = home / "cron" + cron_jobs.JOBS_FILE = cron_jobs.CRON_DIR / "jobs.json" + cron_jobs.OUTPUT_DIR = cron_jobs.CRON_DIR / "output" + cron_scheduler._hermes_home = home + try: + return getattr(cron_scheduler, func_name)(*args, **kwargs) + finally: + cron_scheduler._hermes_home = old_scheduler_home + cron_jobs.CRON_DIR = old_cron_dir + cron_jobs.JOBS_FILE = old_jobs_file + cron_jobs.OUTPUT_DIR = old_output_dir + + def _find_cron_job_profile(job_id: str) -> Optional[str]: for profile in _cron_profile_dicts(): name = str(profile.get("name") or "") @@ -6955,7 +6979,9 @@ async def trigger_cron_job(job_id: str, profile: Optional[str] = None): job = _call_cron_for_profile(selected, "trigger_job", job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") - return job + _call_cron_scheduler_for_profile(selected, "tick") + refreshed = _call_cron_for_profile(selected, "get_job", job_id) + return refreshed or job @app.delete("/api/cron/jobs/{job_id}") diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index bf8f6e219c3df..a583f04d7b460 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -1,5 +1,7 @@ """Regression tests for dashboard cron job profile routing.""" +from unittest.mock import patch + import pytest from fastapi import HTTPException @@ -50,6 +52,37 @@ def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_prof assert cron_jobs.OUTPUT_DIR == old_output_dir +def test_call_cron_scheduler_for_profile_routes_home_and_restores_globals(isolated_profiles): + from cron import jobs as cron_jobs + from cron import scheduler as cron_scheduler + from hermes_cli import web_server + + observed = {} + old_cron_dir = cron_jobs.CRON_DIR + old_jobs_file = cron_jobs.JOBS_FILE + old_output_dir = cron_jobs.OUTPUT_DIR + old_scheduler_home = cron_scheduler._hermes_home + + def fake_tick(): + observed["cron_dir"] = cron_jobs.CRON_DIR + observed["jobs_file"] = cron_jobs.JOBS_FILE + observed["output_dir"] = cron_jobs.OUTPUT_DIR + observed["scheduler_home"] = cron_scheduler._hermes_home + + with patch("cron.scheduler.tick", side_effect=fake_tick): + web_server._call_cron_scheduler_for_profile("worker_alpha", "tick") + + assert observed["cron_dir"] == isolated_profiles["worker_alpha"] / "cron" + assert observed["jobs_file"] == isolated_profiles["worker_alpha"] / "cron" / "jobs.json" + assert observed["output_dir"] == isolated_profiles["worker_alpha"] / "cron" / "output" + assert observed["scheduler_home"] == isolated_profiles["worker_alpha"] + + assert cron_jobs.CRON_DIR == old_cron_dir + assert cron_jobs.JOBS_FILE == old_jobs_file + assert cron_jobs.OUTPUT_DIR == old_output_dir + assert cron_scheduler._hermes_home == old_scheduler_home + + @pytest.mark.asyncio async def test_list_cron_jobs_all_includes_default_and_named_profiles(isolated_profiles): from hermes_cli import web_server @@ -131,6 +164,38 @@ async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_pr assert worker_jobs[0]["enabled"] is False +@pytest.mark.asyncio +async def test_trigger_cron_job_ticks_selected_profile_and_returns_refreshed_job(isolated_profiles): + from cron import jobs as cron_jobs + from hermes_cli import web_server + + worker_job = web_server._call_cron_for_profile( + "worker_alpha", + "create_job", + prompt="run immediately", + schedule="every 1h", + name="run-now-worker", + ) + + def fake_tick(): + cron_jobs.mark_job_run(worker_job["id"], success=True) + + with patch("cron.scheduler.tick", side_effect=fake_tick) as tick_mock: + triggered = await web_server.trigger_cron_job( + worker_job["id"], profile="worker_alpha" + ) + + tick_mock.assert_called_once_with() + assert triggered["profile"] == "worker_alpha" + assert triggered["last_status"] == "ok" + assert triggered["last_run_at"] is not None + + default_jobs = await web_server.list_cron_jobs(profile="default") + worker_jobs = await web_server.list_cron_jobs(profile="worker_alpha") + assert default_jobs == [] + assert [job["id"] for job in worker_jobs] == [worker_job["id"]] + + @pytest.mark.asyncio async def test_update_cron_job_rejects_id_mutation(isolated_profiles): """Dashboard surfaces a 400 (not a 500 or silent rename) when an