diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 9c1fde724be5..c287c868acac 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -2,14 +2,21 @@ Cron subcommand for hermes CLI. Handles standalone cron management commands like list, create, edit, -pause/resume/run/remove, status, and tick. +pause/resume/run/remove, status, tick, and daemon. """ import json +import logging +import re +import signal import sys +import threading +import time from pathlib import Path from typing import Iterable, List, Optional +logger = logging.getLogger(__name__) + PROJECT_ROOT = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(PROJECT_ROOT)) @@ -456,6 +463,43 @@ def _job_action(action: str, job_id: str, success_verb: str) -> int: return 0 +def cron_daemon(args): + """Run the cron scheduler as a standalone daemon (independent of gateway).""" + interval = getattr(args, 'interval', 60) + stop_event = threading.Event() + + def _signal_handler(signum, frame): + logger.info("Shutdown signal received, stopping cron daemon...") + stop_event.set() + + # Only set up signal handlers in the main thread + try: + signal.signal(signal.SIGINT, _signal_handler) + signal.signal(signal.SIGTERM, _signal_handler) + except ValueError: + # Not in main thread (e.g., when imported and called programmatically) + # The caller is responsible for handling shutdown + pass + + logger.info(f"Cron daemon started (interval={interval}s). Press Ctrl+C to stop.") + print(f"Cron daemon started (interval={interval}s). Press Ctrl+C to stop.") + + tick_count = 0 + while not stop_event.is_set(): + try: + from cron.scheduler import tick + tick(verbose=True, sync=True) + except Exception as e: + logger.error(f"Cron tick error: {e}") + + tick_count += 1 + stop_event.wait(timeout=interval) + + logger.info("Cron daemon stopped") + print("Cron daemon stopped") + return 0 + + def cron_command(args): """Handle cron subcommands.""" subcmd = getattr(args, 'cron_command', None) @@ -477,6 +521,9 @@ def cron_command(args): cron_runs(getattr(args, "job_id", None), getattr(args, "limit", 20)) return 0 + if subcmd == "daemon": + return cron_daemon(args) + if subcmd in {"create", "add"}: return cron_create(args) @@ -496,5 +543,5 @@ def cron_command(args): return _job_action("remove", args.job_id, "Removed") print(f"Unknown cron command: {subcmd}") - print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|status|runs|tick]") + print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|status|runs|tick|daemon]") sys.exit(1) diff --git a/hermes_cli/subcommands/cron.py b/hermes_cli/subcommands/cron.py index cce0c827eda1..ec8ee12f7aa3 100644 --- a/hermes_cli/subcommands/cron.py +++ b/hermes_cli/subcommands/cron.py @@ -165,5 +165,15 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None: # cron tick (mostly for debugging) cron_tick = cron_subparsers.add_parser("tick", help="Run due jobs once and exit") add_accept_hooks_flag(cron_tick) + + # cron daemon (standalone cron scheduler) + cron_daemon_parser = cron_subparsers.add_parser( + "daemon", help="Run cron scheduler as standalone daemon (independent of gateway)" + ) + cron_daemon_parser.add_argument( + "--interval", type=int, default=60, help="Tick interval in seconds (default: 60)" + ) + add_accept_hooks_flag(cron_daemon_parser) + add_accept_hooks_flag(cron_parser) cron_parser.set_defaults(func=cmd_cron)