Files
aios/active-runtime/app/self_loop_scheduler.py
T

164 lines
5.2 KiB
Python

"""Autonomous self-loop scheduler.
Schedules event-triggered and interval-style self-loop cycles. The scheduler is
framework-neutral and deterministic: production code can invoke tick() from a
cron, worker, or webhook handler without giving the module direct access to
secrets, shell commands, or main-branch writes.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any
from uuid import uuid4
from .self_loop_patch_executor import SelfLoopPatchExecutor
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
class TriggerType(str, Enum):
INTERVAL = "interval"
EVENT = "event"
MANUAL = "manual"
class JobStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
WAITING_OWNER = "waiting_owner"
FAILED = "failed"
RETRY_PENDING = "retry_pending"
@dataclass
class SelfLoopJob:
id: str
goal: str
trigger: TriggerType
runtime: dict[str, Any]
baseline: dict[str, Any]
branch: str = "patch/autonomous-self-loop"
status: JobStatus = JobStatus.PENDING
attempts: int = 0
max_attempts: int = 3
last_error: str | None = None
created_at: str = field(default_factory=utc_now)
updated_at: str = field(default_factory=utc_now)
result: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["trigger"] = self.trigger.value
data["status"] = self.status.value
return data
@dataclass(frozen=True)
class SchedulerSnapshot:
pending: int
running: int
completed: int
waiting_owner: int
failed: int
retry_pending: int
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class SelfLoopScheduler:
"""Small in-process scheduler used by runtime workers and tests."""
def __init__(self, executor: SelfLoopPatchExecutor | None = None):
self.executor = executor or SelfLoopPatchExecutor()
self.jobs: dict[str, SelfLoopJob] = {}
def enqueue(
self,
goal: str,
runtime: dict[str, Any],
baseline: dict[str, Any],
trigger: TriggerType | str = TriggerType.EVENT,
branch: str = "patch/autonomous-self-loop",
max_attempts: int = 3,
) -> SelfLoopJob:
trigger_value = trigger if isinstance(trigger, TriggerType) else TriggerType(trigger)
job = SelfLoopJob(
id=f"job_{uuid4().hex[:12]}",
goal=goal,
trigger=trigger_value,
runtime=runtime,
baseline=baseline,
branch=branch,
max_attempts=max_attempts,
)
self.jobs[job.id] = job
return job
def tick(self, limit: int = 1) -> list[dict[str, Any]]:
runnable = [j for j in self.jobs.values() if j.status in {JobStatus.PENDING, JobStatus.RETRY_PENDING}]
results: list[dict[str, Any]] = []
for job in runnable[:limit]:
results.append(self.run_job(job.id))
return results
def run_job(self, job_id: str) -> dict[str, Any]:
job = self.jobs[job_id]
job.status = JobStatus.RUNNING
job.attempts += 1
job.updated_at = utc_now()
try:
result = self.executor.run_cycle_to_promotion(job.runtime, job.baseline, job.goal, job.branch)
job.result = result
promotion_status = result.get("promotion", {}).get("status")
if promotion_status == "waiting_owner_approval":
job.status = JobStatus.WAITING_OWNER
elif promotion_status == "ready_for_auto_merge":
job.status = JobStatus.COMPLETED
elif job.attempts < job.max_attempts:
job.status = JobStatus.RETRY_PENDING
job.last_error = promotion_status or "promotion_not_ready"
else:
job.status = JobStatus.FAILED
job.last_error = promotion_status or "promotion_failed"
except Exception as exc: # pragma: no cover - defensive runtime guard
job.last_error = f"{type(exc).__name__}: {exc}"
job.status = JobStatus.RETRY_PENDING if job.attempts < job.max_attempts else JobStatus.FAILED
job.updated_at = utc_now()
return job.to_dict()
def retry_failed(self) -> list[str]:
retry_ids: list[str] = []
for job in self.jobs.values():
if job.status == JobStatus.FAILED and job.attempts < job.max_attempts:
job.status = JobStatus.RETRY_PENDING
job.updated_at = utc_now()
retry_ids.append(job.id)
return retry_ids
def snapshot(self) -> SchedulerSnapshot:
statuses = [job.status for job in self.jobs.values()]
return SchedulerSnapshot(
pending=statuses.count(JobStatus.PENDING),
running=statuses.count(JobStatus.RUNNING),
completed=statuses.count(JobStatus.COMPLETED),
waiting_owner=statuses.count(JobStatus.WAITING_OWNER),
failed=statuses.count(JobStatus.FAILED),
retry_pending=statuses.count(JobStatus.RETRY_PENDING),
)
__all__ = [
"TriggerType",
"JobStatus",
"SelfLoopJob",
"SchedulerSnapshot",
"SelfLoopScheduler",
]