164 lines
5.2 KiB
Python
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",
|
|
]
|