Files
AIPass/src/aipass/daemon/apps/scheduler_cron.py
T
AIOSAIandClaude Opus 4.8 cbe3ba66c6 feat(daemon): decentralized .daemon scheduler — branches own schedule.json (DPLAN-0204/FPLAN-0282)
Each branch owns .daemon/schedule.json; daemon does discovery + firing via
wake_branch() direct (path A). Owner IS the wake target, killing stale-target
bugs. Proven live: drone @daemon run -> discovered @commons/wake-test ->
wake_branch() fired -> @commons woke -> @devpulse received 'DAEMON TEST FIRED'.

- handlers/schedule/discovery.py  — scan branches for .daemon/schedule.json
- handlers/schedule/runstate.py   — per-job run-state tracking
- modules/run.py                  — run-tick entry (wired into 5 modules)
- scheduler_cron.py               — rewired process_actions -> run_tick
- old plugins (community_rotation, daily_audit, heartbeat) retired -> .archive
- 448 tests (+61)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:29:22 -07:00

403 lines
12 KiB
Python
Executable File

# =================== AIPass ====================
# Name: scheduler_cron.py
# Description: DAEMON Scheduler Cron Trigger
# Version: 2.0.0
# Created: 2026-02-15
# Modified: 2026-03-24
# =============================================
"""
Cron trigger script for the DAEMON scheduled task system.
Called periodically by cron. Standalone script -- not imported as a module.
Flow:
1. Acquire single-instance lock
2. Recover stale dispatches
3. Process all due tasks (send emails, mark complete)
4. Process actions from registry
5. Log summary
"""
# =============================================
# IMPORTS
# =============================================
import sys
import time
import subprocess
from pathlib import Path
from datetime import datetime
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console
from aipass.daemon.apps.handlers.json import json_handler
# action_processor retired — new tick uses .daemon/ discovery (DPLAN-0204)
from aipass.daemon.apps.modules.run import run_tick
try:
import fcntl
except ImportError:
fcntl = None # type: ignore[assignment]
logger.info("[DAEMON] scheduler_cron: fcntl unavailable (Windows)")
# =============================================
# OPTIONAL IMPORTS (via module layer)
# =============================================
# Task registry (via module layer)
try:
from aipass.daemon.apps.modules.scheduler_ops import (
get_due_tasks,
mark_dispatching,
mark_completed,
mark_pending,
recover_stale_dispatches,
TASK_REGISTRY_AVAILABLE,
)
except ImportError as e:
logger.info(f"Optional dependency not available: scheduler_ops task registry ({e})")
TASK_REGISTRY_AVAILABLE = False
get_due_tasks = None
mark_dispatching = None
mark_completed = None
mark_pending = None
recover_stale_dispatches = None
# Email integration via drone subprocess
def _send_email_via_drone(
to_branch, subject, message, from_branch="@daemon", auto_execute=True, reply_to=None, **kwargs
):
"""Send email via drone @ai_mail send subprocess."""
cmd = ["drone", "@ai_mail", "send", to_branch, subject, message]
if auto_execute:
cmd.append("--dispatch")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return result.returncode == 0
except (subprocess.SubprocessError, OSError) as e:
logger.warning(f"Drone email subprocess failed: {e}")
return False
AI_MAIL_AVAILABLE = True
send_email_direct = _send_email_via_drone
# =============================================
# CONSTANTS
# =============================================
_DAEMON_ROOT = Path(__file__).resolve().parents[2] # src/aipass/daemon/
JSON_DIR = _DAEMON_ROOT / "daemon_json"
EVENT_NAME = "cron-run"
LOCK_FILE = JSON_DIR / "schedule.lock"
STALE_DISPATCH_MAX_AGE = 5 # minutes
# =============================================
# LOGGING
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]scheduler_cron Module[/bold cyan]")
console.print()
console.print("[dim]Cron trigger for scheduled tasks and action registry processing[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" modules/")
console.print(
" [cyan]*[/cyan] scheduler_ops.py [dim](task registry ops + action registry ops, notifications archived)[/dim]"
)
console.print()
console.print(" plugins/")
console.print(" [cyan]*[/cyan] discover_plugins [dim](plugin discovery and scheduled execution)[/dim]")
console.print()
def print_help() -> None:
"""Display usage information for scheduler_cron."""
console.print("\n[bold cyan]scheduler_cron.py - DAEMON Scheduler Cron Trigger[/bold cyan]")
console.print("\n[yellow]USAGE:[/yellow]")
console.print(" drone @daemon scheduler_cron Run the cron scheduler")
console.print(" drone @daemon scheduler_cron --help Show this help message")
console.print("\n[yellow]DESCRIPTION:[/yellow]")
console.print(" Processes due scheduled tasks and actions from the registry.")
console.print(" Intended to be called periodically by cron.")
console.print()
def log(message: str) -> None:
"""Print timestamped log line to stdout (captured by cron redirect)."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
console.print(f"[{timestamp}] {message}")
# =============================================
# TASK PROCESSING
# =============================================
def process_due_tasks() -> dict:
"""
Process all due scheduled tasks.
Recovers stale dispatches, then iterates due tasks:
mark dispatching -> send email -> mark completed or reset to pending.
Returns:
Dict with keys: due, success, failed, errors (list of error strings)
"""
results = {
"due": 0,
"success": 0,
"failed": 0,
"recovered": 0,
"errors": [],
}
if not TASK_REGISTRY_AVAILABLE:
log("WARNING: Task registry not available, skipping task processing")
return results
# Recover any stale dispatches (stuck > 5 minutes)
try:
recovered = recover_stale_dispatches(max_age_minutes=STALE_DISPATCH_MAX_AGE) # type: ignore[misc]
results["recovered"] = recovered
if recovered:
log(f"Recovered {recovered} stale dispatch(es)")
except Exception as e:
logger.warning(f"Failed to recover stale dispatches: {e}")
log(f"WARNING: Failed to recover stale dispatches: {e}")
results["errors"].append(f"Stale recovery: {e}")
# Get due tasks
try:
due_tasks = get_due_tasks() # type: ignore[misc]
except Exception as e:
logger.error(f"Failed to load due tasks: {e}")
log(f"ERROR: Failed to load due tasks: {e}")
results["errors"].append(f"Load tasks: {e}")
return results
results["due"] = len(due_tasks)
if not due_tasks:
log("No tasks due at this time.")
return results
log(f"Found {len(due_tasks)} due task(s)")
# Process each due task
for task in due_tasks:
_process_single_task(task, results)
# Small delay between dispatches (prevents thundering herd)
time.sleep(1.0)
return results
def _process_single_task(task: dict, results: dict) -> None:
"""Process a single due task: mark dispatching, send email, update results."""
task_id = task.get("id", "")
recipient = task.get("recipient", "")
task_desc = task.get("task", "")
message = task.get("message", "")
log(f"Processing: {task_id[:8]} -> {recipient}: {task_desc[:50]}")
# Mark as dispatching (prevents re-dispatch)
try:
mark_dispatching(task_id) # type: ignore[misc]
except Exception as e:
logger.warning(f"Failed to mark dispatching {task_id[:8]}: {e}")
log(f"WARNING: Failed to mark dispatching {task_id[:8]}: {e}")
results["errors"].append(f"Mark dispatching {task_id[:8]}: {e}")
results["failed"] += 1
return
# Build email body
email_body = f"{task_desc}"
if message:
email_body += f"\n\nDetails:\n{message}"
# Send the email
if not AI_MAIL_AVAILABLE:
log(f"SKIP: ai_mail not available, cannot send to {recipient}")
mark_pending(task_id) # type: ignore[misc]
results["failed"] += 1
results["errors"].append(f"ai_mail unavailable for {task_id[:8]}")
return
try:
email_sent = send_email_direct(
to_branch=recipient,
subject=f"[SCHEDULED] {task_desc}",
message=email_body,
from_branch="@daemon",
auto_execute=True,
reply_to="@devpulse",
)
if email_sent:
mark_completed(task_id) # type: ignore[misc]
log(f"OK: Sent to {recipient}: {task_desc[:40]}")
results["success"] += 1
else:
mark_pending(task_id) # type: ignore[misc]
log(f"FAIL: Email returned False for {recipient}: {task_desc[:40]}")
results["failed"] += 1
results["errors"].append(f"Email failed: {task_id[:8]} -> {recipient}")
except Exception as e:
# Reset to pending for retry on next run
try:
mark_pending(task_id) # type: ignore[misc]
except Exception as reset_err:
logger.warning(f"Best-effort reset to pending failed for {task_id[:8]}: {reset_err}")
logger.error(f"Exception sending to {recipient}: {e}")
log(f"ERROR: Exception sending to {recipient}: {e}")
results["failed"] += 1
results["errors"].append(f"Email error {task_id[:8]}: {e}")
def _next_cron_run() -> str:
"""Calculate approximate next scheduler cron run time."""
now = datetime.now()
if now.minute < 30:
next_min = 30
next_hour = now.hour
else:
next_min = 0
next_hour = (now.hour + 1) % 24
return f"{next_hour:02d}:{next_min:02d}"
# =============================================
# MAIN
# =============================================
def main() -> int:
"""
Main cron entry point.
Returns:
0 on success, 1 on error
"""
args = sys.argv[1:]
if not args:
print_introspection()
return 0
if args[0] in ["--version", "-V"]:
console.print("scheduler_cron v2.0.0")
return 0
if args[0] in ["--help", "-h"]:
print_help()
sys.exit(0)
json_handler.log_operation("cron_run")
log("=" * 60)
log("Scheduler cron triggered")
# Ensure lock directory exists
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
# Acquire single-instance lock (non-blocking, stdlib fcntl)
if fcntl is None:
log("fcntl not available (non-Unix platform), skipping lock.")
return _run_locked()
lock_fd = open(LOCK_FILE, "w", encoding="utf-8") # noqa: SIM115
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as e:
logger.warning(f"Scheduler lock acquisition failed (another instance running): {e}")
log("Another instance already running, skipping.")
lock_fd.close()
return 0
try:
return _run_locked()
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
def _run_locked() -> int:
"""Execute the cron job while holding the lock."""
exit_code = 0
# Step 1: Process due tasks
try:
results = process_due_tasks()
except Exception as e:
logger.error(f"Unhandled error in process_due_tasks: {e}", exc_info=True)
log(f"CRITICAL: Unhandled error in process_due_tasks: {e}")
return 1
# Step 2: Run decentralized .daemon/ scheduler tick
tick_results = {"fired": 0, "failed": 0}
try:
tick_results = run_tick()
except Exception as e:
logger.warning(f"Unhandled error in run_tick: {e}")
log(f"WARNING: Unhandled error in scheduler tick: {e}")
# Step 3: Build summary
lines = []
# Tasks section
if results["recovered"]:
lines.append(f"Recovered {results['recovered']} stale dispatch(es)")
if results["due"] or results["success"]:
task_line = f"Tasks: {results['due']} due | {results['success']} sent"
if results["failed"]:
task_line += f" | {results['failed']} failed"
lines.append(task_line)
else:
lines.append("Tasks: none due")
# Scheduler tick section
if tick_results.get("fired") or tick_results.get("failed"):
lines.append(f"Scheduler: {tick_results.get('fired', 0)} fired, {tick_results.get('failed', 0)} failed")
else:
lines.append(f"Scheduler: {tick_results.get('discovered', 0)} discovered, none due")
# Next run
lines.append(f"Next: ~{_next_cron_run()}")
summary = "\n".join(lines)
log(f"Results: {summary}")
# Step 4: Determine exit code
if results["failed"] > 0 or results["errors"] or tick_results.get("failed", 0) > 0:
exit_code = 1
log("Scheduler cron finished")
if exit_code == 0:
logger.info("[DAEMON] scheduler_cron: Cron cycle completed successfully")
log("=" * 60)
return exit_code
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e:
# Last-resort catch -- never crash silently
logger.error(f"FATAL scheduler_cron exception: {e}", exc_info=True)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
console.print(f"[{timestamp}] FATAL: Unhandled exception: {e}")
sys.exit(1)