stage-8: cron scheduler with APScheduler

Scheduler class, cron/jobs.json, Discord /cron commands, CLI cron subcommand, job lifecycle management. 88 new tests (281 total).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 16:12:56 +00:00
parent 09d3de003a
commit 24a4d87f8c
8 changed files with 1640 additions and 1 deletions

142
cli.py
View File

@@ -292,6 +292,119 @@ def cmd_send(args):
print(response)
def cmd_cron(args):
"""Handle cron subcommand."""
if args.cron_action == "list":
_cron_list()
elif args.cron_action == "run":
_cron_run(args.name)
elif args.cron_action == "add":
tools = [t.strip() for t in args.tools.split(",")] if args.tools else []
if args.prompt == "-":
prompt = sys.stdin.read().strip()
else:
prompt = args.prompt
_cron_add(args.name, args.expression, args.channel, prompt,
args.model, tools)
elif args.cron_action == "remove":
_cron_remove(args.name)
elif args.cron_action == "enable":
_cron_enable(args.name)
elif args.cron_action == "disable":
_cron_disable(args.name)
def _cron_list():
"""List scheduled jobs in tabular format."""
from src.scheduler import Scheduler
s = Scheduler()
s._jobs = s._load_jobs()
jobs = s.list_jobs()
if not jobs:
print("No scheduled jobs.")
return
print(f"{'Name':<24} {'Cron':<16} {'Channel':<10} {'Model':<8} {'Enabled':<8} {'Last Status':<12} {'Next Run'}")
print(f"{'-'*24} {'-'*16} {'-'*10} {'-'*8} {'-'*8} {'-'*12} {'-'*20}")
for job in jobs:
name = job.get("name", "?")
cron = job.get("cron", "?")
channel = job.get("channel", "?")
model = job.get("model", "?")
enabled = "yes" if job.get("enabled") else "no"
last_status = job.get("last_status") or "-"
next_run = job.get("next_run") or "-"
if next_run != "-" and len(next_run) > 19:
next_run = next_run[:19].replace("T", " ")
print(f"{name:<24} {cron:<16} {channel:<10} {model:<8} {enabled:<8} {last_status:<12} {next_run}")
def _cron_run(name: str):
"""Force-run a job and print output."""
import asyncio
from src.scheduler import Scheduler
async def _run():
s = Scheduler()
s._jobs = s._load_jobs()
return await s.run_job(name)
try:
result = asyncio.run(_run())
print(result)
except KeyError as exc:
print(f"Error: {exc}")
sys.exit(1)
def _cron_add(name: str, cron: str, channel: str, prompt: str,
model: str, tools: list[str]):
"""Add a new cron job."""
from src.scheduler import Scheduler
s = Scheduler()
s._jobs = s._load_jobs()
try:
job = s.add_job(name, cron, channel, prompt, model, tools or None)
print(f"Job '{job['name']}' added (cron: {job['cron']}, channel: {job['channel']}, model: {job['model']})")
except ValueError as exc:
print(f"Error: {exc}")
sys.exit(1)
def _cron_remove(name: str):
"""Remove a cron job."""
from src.scheduler import Scheduler
s = Scheduler()
s._jobs = s._load_jobs()
if s.remove_job(name):
print(f"Job '{name}' removed.")
else:
print(f"Job '{name}' not found.")
def _cron_enable(name: str):
"""Enable a cron job."""
from src.scheduler import Scheduler
s = Scheduler()
s._jobs = s._load_jobs()
if s.enable_job(name):
print(f"Job '{name}' enabled.")
else:
print(f"Job '{name}' not found.")
def _cron_disable(name: str):
"""Disable a cron job."""
from src.scheduler import Scheduler
s = Scheduler()
s._jobs = s._load_jobs()
if s.disable_job(name):
print(f"Job '{name}' disabled.")
else:
print(f"Job '{name}' not found.")
def cmd_secrets(args):
"""Handle secrets subcommand."""
if args.secrets_action == "set":
@@ -396,6 +509,32 @@ def main():
secrets_sub.add_parser("test", help="Check required secrets")
# cron
cron_parser = sub.add_parser("cron", help="Manage scheduled jobs")
cron_sub = cron_parser.add_subparsers(dest="cron_action")
cron_sub.add_parser("list", help="List scheduled jobs")
cron_run_p = cron_sub.add_parser("run", help="Force-run a job")
cron_run_p.add_argument("name", help="Job name")
cron_add_p = cron_sub.add_parser("add", help="Add a scheduled job")
cron_add_p.add_argument("name", help="Job name")
cron_add_p.add_argument("expression", help="Cron expression (e.g. '30 6 * * *')")
cron_add_p.add_argument("--channel", required=True, help="Channel alias")
cron_add_p.add_argument("--prompt", required=True, help="Prompt text (use '-' for stdin)")
cron_add_p.add_argument("--model", default="sonnet", help="Model (default: sonnet)")
cron_add_p.add_argument("--tools", default=None, help="Comma-separated allowed tools")
cron_remove_p = cron_sub.add_parser("remove", help="Remove a job")
cron_remove_p.add_argument("name", help="Job name")
cron_enable_p = cron_sub.add_parser("enable", help="Enable a job")
cron_enable_p.add_argument("name", help="Job name")
cron_disable_p = cron_sub.add_parser("disable", help="Disable a job")
cron_disable_p.add_argument("name", help="Job name")
# Parse and dispatch
args = parser.parse_args()
@@ -415,6 +554,9 @@ def main():
cmd_channel(a) if a.channel_action else (channel_parser.print_help() or sys.exit(0))
),
"send": cmd_send,
"cron": lambda a: (
cmd_cron(a) if a.cron_action else (cron_parser.print_help() or sys.exit(0))
),
"secrets": lambda a: (
cmd_secrets(a) if a.secrets_action else (secrets_parser.print_help() or sys.exit(0))
),