#!/usr/bin/env python3 """ Unified Cronjob Scheduler Runs every 10 minutes via system cron to execute scheduled jobs. Usage: python3 run_cronjob_every_10mins.py [cronjob_name] agent_role: team-admin, team-workflow, team-code-expert cronjob_name: (optional) job name for debugging Examples: # Run all jobs for team-workflow that match current schedule python3 run_cronjob_every_10mins.py team-workflow # Debug: run specific job immediately (ignore schedule) python3 run_cronjob_every_10mins.py team-workflow ai-news """ import os import json import subprocess import argparse from datetime import datetime from pathlib import Path # Detect base directory - works both locally and in container SCRIPT_DIR = Path(__file__).parent.resolve() CRONJOBS_DIR = SCRIPT_DIR BASE_DIR = CRONJOBS_DIR.parent # repo root LOGS_DIR = Path(os.path.expanduser("~/CRONJOB_LOGS")) FEISHU_NOTIFY_CHAT_ID = "oc_2c586abb5204eca2ddf6ddf5de338bf6" def get_normalized_time(): """Round down current time to nearest 10 minutes.""" now = datetime.now() normalized_minute = (now.minute // 10) * 10 return now.replace(minute=normalized_minute, second=0, microsecond=0) def matches_schedule(job, current_time): """Check if job matches current time schedule.""" # Parse schedule from cron format (minute hour day month day_of_week) schedule = job.get("schedule", {}) expr = schedule.get("expr", "") if isinstance(schedule, dict) else str(schedule) if not expr: return False parts = schedule.split() if len(parts) < 5: return False _, hour_part, day_part, _, dow_part = parts # Check minute (supports list or single value) minute_part = parts[0] minute_set = parse_cron_field(minute_part) if minute_set != "*" and current_time.minute not in minute_set: return False # Check hour hour_set = parse_cron_field(hour_part) if hour_set != "*" and current_time.hour not in hour_set: return False # Check day of month if day_part != "*": day_set = parse_cron_field(day_part) if current_time.day not in day_set: return False # Check day of week (0=Monday in Python, but cron uses 0=Sunday) if dow_part != "*": # Convert Python weekday (0=Monday) to cron format (0=Sunday) cron_dow = (current_time.weekday() + 1) % 7 dow_set = parse_cron_field(dow_part) if cron_dow not in dow_set: return False return True def parse_cron_field(field): """Parse cron field like '0', '0,10', '*/10', '1,3,5'.""" result = set() if field == "*": return "*" for part in field.split(","): if "/" in part: # Handle */n format base, step = part.split("/") step = int(step) if base == "*": # */10 means 0, 10, 20, 30, ... for i in range(0, 60, step): result.add(i) elif "-" in part: # Handle range like 1-5 start, end = map(int, part.split("-")) for i in range(start, end + 1): result.add(i) else: result.add(int(part)) return result def load_config(agent_role): """Load cronjob config for given agent role.""" # Try cronjob.json first, then _config/jobs.json config_path = CRONJOBS_DIR / agent_role / "cronjob.json" if not config_path.exists(): config_path = CRONJOBS_DIR / agent_role / "_config" / "jobs.json" if not config_path.exists(): print(f"Config not found: {config_path}") return None with open(config_path) as f: return json.load(f) def get_job_command(agent_role, job_name, config): """Get the command to run for a job from config.""" jobs = config.get("jobs", []) for job in jobs: if job.get("name") == job_name: return job.get("command") return None def execute_job(command, job_name): """Execute a job command and return result.""" print(f"Executing: {command}") import shlex # Load environment variables from ~/.bashrc (source it so vars are expanded) env = os.environ.copy() # Remove testing-only flags that should not leak into cronjob environment env.pop("SKIP_UPLOAD_FEISHU_DOC", None) bashrc = os.path.expanduser("~/.bashrc") if os.path.exists(bashrc): result = subprocess.run( ["bash", "-c", f"source {bashrc} && env"], capture_output=True, text=True ) if result.returncode == 0: for line in result.stdout.splitlines(): if "=" in line: key, _, value = line.partition("=") if key.startswith("CRONJOB_") or key.startswith("MINIMAX_") or key.startswith("GITHUB_"): env.setdefault(key, value) try: # Parse command and run in BASE_DIR cmd_parts = shlex.split(command) result = subprocess.run( cmd_parts, capture_output=True, text=True, timeout=600, # 10 minutes timeout cwd=str(BASE_DIR), env=env ) return { "success": result.returncode == 0, "returncode": result.returncode, "stdout": result.stdout, "stderr": result.stderr } except subprocess.TimeoutExpired: return { "success": False, "returncode": -1, "stdout": "", "stderr": "Job timed out after 10 minutes" } except Exception as e: return { "success": False, "returncode": -1, "stdout": "", "stderr": str(e) } def log_to_file(agent_role, message): """Append message to daily log file.""" LOGS_DIR.mkdir(parents=True, exist_ok=True) today = datetime.now().strftime("%Y%m%d") log_file = LOGS_DIR / f"cron_{today}.log" timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with open(log_file, "a") as f: f.write(f"[{timestamp}] [{agent_role}] {message}\n") def send_feishu_notification(failed_jobs, agent_role="team-admin"): """Send notification to Feishu about failed jobs. Uses role-specific app credentials.""" if not failed_jobs: return try: import urllib.request, json # Get app credentials from env vars based on agent role app_id_key = f"CRONJOB_{agent_role.upper().replace('-', '_')}_APP_ID" app_secret_key = f"CRONJOB_{agent_role.upper().replace('-', '_')}_APP_SECRET" app_id = os.environ.get(app_id_key) app_secret = os.environ.get(app_secret_key) if not app_id or not app_secret: print(f"Feishu credentials not found for {agent_role} ({app_id_key}, {app_secret_key})") return # Get tenant access token req = urllib.request.Request( "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", data=json.dumps({"app_id": app_id, "app_secret": app_secret}).encode(), headers={"Content-Type": "application/json"}, method="POST" ) with urllib.request.urlopen(req, timeout=10) as resp: token = json.loads(resp.read())["tenant_access_token"] # Build message lines = [f"⚠️ [{agent_role}] Cronjob Failed\n"] for job in failed_jobs: lines.append(f"- {job['name']}: {job['error']}") # Send to Wang Bing req2 = urllib.request.Request( "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id", data=json.dumps({ "receive_id": "ou_f442e20fd980363ea486b5a6cd8d2a2f", "msg_type": "text", "content": json.dumps({"text": "\n".join(lines)}) }).encode(), headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, method="POST" ) with urllib.request.urlopen(req2, timeout=10) as resp2: print(f"Feishu alert sent: {resp2.read()[:100]}") except Exception as e: print(f"Feishu notification failed: {e}") def main(): parser = argparse.ArgumentParser(description="Unified Cronjob Scheduler") parser.add_argument("agent_role", help="Agent role: team-admin, team-workflow, team-code-expert") parser.add_argument("cronjob_name", nargs="?", help="Job name for debugging (runs immediately)") args = parser.parse_args() agent_role = args.agent_role cronjob_name = args.cronjob_name log_to_file(agent_role, "=== Scheduler started ===") # Load config config = load_config(agent_role) if not config: log_to_file(agent_role, f"Config not found for {agent_role}") return jobs = config.get("jobs", []) if cronjob_name: # Debug mode: run specific job immediately matching_jobs = [j for j in jobs if j["name"] == cronjob_name] if not matching_jobs: print(f"Job not found: {cronjob_name}") log_to_file(agent_role, f"Job not found: {cronjob_name}") return print(f"Debug mode: running {cronjob_name} immediately") else: # Normal mode: check schedule normalized_time = get_normalized_time() print(f"Current normalized time: {normalized_time.strftime('%Y-%m-%d %H:%M')}") log_to_file(agent_role, f"Normalized time: {normalized_time}") # Find matching jobs matching_jobs = [j for j in jobs if matches_schedule(j, normalized_time)] if not matching_jobs: print("No jobs to run at this time") log_to_file(agent_role, "No jobs matched schedule") return print(f"Jobs to execute: {[j['name'] for j in matching_jobs]}") log_to_file(agent_role, f"Jobs to run: {[j['name'] for j in matching_jobs]}") # Execute jobs failed_jobs = [] for job in matching_jobs: job_name = job["name"] command = get_job_command(agent_role, job_name, config) if not command: print(f"Command not found for job: {job_name}") log_to_file(agent_role, f"Command not found: {job_name}") failed_jobs.append({ "name": job_name, "error": "Command not found" }) continue print(f"\n--- Running job: {job_name} ---") result = execute_job(command, job_name) if result["success"]: print(f"✓ {job_name} completed successfully") log_to_file(agent_role, f"Job {job_name} succeeded") else: error_msg = result["stderr"] or f"Exit code: {result['returncode']}" print(f"✗ {job_name} failed: {error_msg}") log_to_file(agent_role, f"Job {job_name} failed: {error_msg}") failed_jobs.append({ "name": job_name, "error": error_msg }) # Send notification for failed jobs (pass agent_role so correct app credentials are used) if failed_jobs: send_feishu_notification(failed_jobs, agent_role) log_to_file(agent_role, "=== Scheduler finished ===\n") if __name__ == "__main__": main()