feat: add hermes cron run --now for immediate job execution (closes #347)
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 24s

Problem: 'hermes cron run JOBID' only queues for next scheduler tick.
Stale error state (like tool_choice TypeError residue) persists forever
because there's no way to execute a job immediately and get fresh results.

Solution: Three-layer synchronous execution path:
- cron/jobs.py: run_job_now() calls scheduler.run_job() then mark_job_run()
- gateway: POST /api/jobs/{id}/run-now endpoint (runs in thread executor)
- CLI: hermes cron run JOBID --now executes and prints result immediately
- tools/cronjob_tools.py: 'run_now' action routes to new function

Also fixes #346, #349 (same stale error pattern).
This commit is contained in:
Timmy
2026-04-13 09:58:47 -04:00
parent c88b172bd9
commit f6e72c135c
5 changed files with 99 additions and 3 deletions

View File

@@ -973,6 +973,7 @@ class APIServerAdapter(BasePlatformAdapter):
pause_job as _cron_pause,
resume_job as _cron_resume,
trigger_job as _cron_trigger,
run_job_now as _cron_run_now,
)
# Wrap as staticmethod to prevent descriptor binding — these are plain
# module functions, not instance methods. Without this, self._cron_*()
@@ -986,6 +987,7 @@ class APIServerAdapter(BasePlatformAdapter):
_cron_pause = staticmethod(_cron_pause)
_cron_resume = staticmethod(_cron_resume)
_cron_trigger = staticmethod(_cron_trigger)
_cron_run_now = staticmethod(_cron_run_now)
_CRON_AVAILABLE = True
except ImportError:
pass
@@ -1204,6 +1206,28 @@ class APIServerAdapter(BasePlatformAdapter):
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def _handle_run_job_now(self, request: "web.Request") -> "web.Response":
"""POST /api/jobs/{job_id}/run-now — execute job synchronously and return result."""
auth_err = self._check_auth(request)
if auth_err:
return auth_err
cron_err = self._check_jobs_available()
if cron_err:
return cron_err
job_id, id_err = self._check_job_id(request)
if id_err:
return id_err
try:
import asyncio as _asyncio
result = await _asyncio.get_event_loop().run_in_executor(
None, self._cron_run_now, job_id
)
if result is None:
return web.json_response({"error": "Job not found"}, status=404)
return web.json_response(result)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# ------------------------------------------------------------------
# Output extraction helper
# ------------------------------------------------------------------
@@ -1565,6 +1589,7 @@ class APIServerAdapter(BasePlatformAdapter):
self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
self._app.router.add_post("/api/jobs/{job_id}/run-now", self._handle_run_job_now)
# Structured event streaming
self._app.router.add_post("/v1/runs", self._handle_runs)
self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)