Files
kaizen-agentic/src/kaizen_agentic/schedule.py
tegwick 3b2edd4a9e feat: scheduled agent execution via activity-core (WP-0006, v1.3.0)
Enable kaizen agents to run on a regular cadence against a preselected repo
roster, orchestrated by activity-core and prepared by kaizen-agentic — without
this repo owning cron, Temporal workers, or an LLM runtime.

CLI + module:
- src/kaizen_agentic/schedule.py — .kaizen/schedule.yml parse/validate/scaffold
- `kaizen-agentic schedule` group: init, validate, list, prepare <agent>
  (prepare bundles agent prompt + memory + metrics + repo pointers, offline)
- tests/test_schedule_cli.py — 15 tests

Contract & design:
- ADR-005 scheduled agent execution; schema doc + example manifest
- discover_kaizen_scheduled_repos resolver spec, state-hub roster fields,
  kaizen.schedule.prepared event payload, activity-core handoff checklist
- INTEGRATION_PATTERNS Pattern 2 extended with roster model

ActivityDefinition drafts (enabled: false):
- weekly-coach-orientation, weekly-optimization-review

Docs: agency-framework, CLI cheat sheet, PACKAGE_RELEASE runner prereqs,
EcosystemIntegration, CHANGELOG, TODO. Workplan closed (status: done).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:19:51 +02:00

186 lines
6.2 KiB
Python

"""Repo-local scheduled agent execution manifest (.kaizen/schedule.yml).
ADR-005 defines the schedule contract: which agents run on what cadence in an
opted-in repo. kaizen-agentic owns parsing, validation, and preparing an
orientation bundle for a scheduled run. It does **not** run cron schedules or
invoke Claude — activity-core fires the cron and a coding-agent session executes
the prepared bundle.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
SCHEDULE_RELATIVE_PATH = Path(".kaizen") / "schedule.yml"
SCHEDULE_VERSION = "1"
VALID_CADENCES = ("daily", "weekly", "monthly")
# Sensible defaults for `schedule init` — coach + optimization weekly, the
# heavier tdd-workflow review monthly and disabled until an operator opts in.
DEFAULT_AGENTS: Dict[str, Dict[str, Any]] = {
"coach": {"cadence": "weekly", "cron": "0 9 * * 1", "enabled": True},
"optimization": {"cadence": "weekly", "cron": "0 10 * * 1", "enabled": True},
"tdd-workflow": {"cadence": "monthly", "enabled": False},
}
DEFAULT_TIMEZONE = "Europe/Berlin"
class ScheduleError(Exception):
"""Raised when a schedule manifest cannot be parsed."""
@dataclass
class ScheduleEntry:
"""One scheduled agent run declaration."""
agent: str
cadence: str
enabled: bool = True
cron: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
data: Dict[str, Any] = {"cadence": self.cadence, "enabled": self.enabled}
if self.cron:
data["cron"] = self.cron
return data
@dataclass
class Schedule:
"""Parsed `.kaizen/schedule.yml` manifest."""
version: str
timezone: Optional[str] = None
entries: List[ScheduleEntry] = field(default_factory=list)
source_path: Optional[Path] = None
def entry_for(self, agent: str) -> Optional[ScheduleEntry]:
for entry in self.entries:
if entry.agent == agent:
return entry
return None
def enabled_entries(self) -> List[ScheduleEntry]:
return [e for e in self.entries if e.enabled]
def schedule_path(project_root: Path) -> Path:
"""Return the canonical schedule.yml path for a project root."""
return Path(project_root) / SCHEDULE_RELATIVE_PATH
def parse_schedule(data: Any, source_path: Optional[Path] = None) -> Schedule:
"""Parse a raw mapping into a Schedule (structural errors raise).
Semantic validation (known agents, cadence values) is handled by
:func:`validate_schedule` so callers can collect actionable error lists.
"""
if not isinstance(data, dict):
raise ScheduleError("schedule.yml must be a YAML mapping at the top level")
version = data.get("version")
if version is None:
raise ScheduleError("schedule.yml is missing required key: version")
version = str(version)
timezone = data.get("timezone")
if timezone is not None and not isinstance(timezone, str):
raise ScheduleError("timezone must be a string")
agents = data.get("agents", {})
if not isinstance(agents, dict):
raise ScheduleError("agents must be a mapping of agent-name -> settings")
entries: List[ScheduleEntry] = []
for name, settings in agents.items():
if settings is None:
settings = {}
if not isinstance(settings, dict):
raise ScheduleError(f"agent '{name}' settings must be a mapping")
cron = settings.get("cron")
if cron is not None and not isinstance(cron, str):
raise ScheduleError(f"agent '{name}' cron must be a string")
entries.append(
ScheduleEntry(
agent=str(name),
cadence=str(settings.get("cadence", "")),
enabled=bool(settings.get("enabled", True)),
cron=cron,
)
)
return Schedule(
version=version,
timezone=timezone,
entries=entries,
source_path=source_path,
)
def load_schedule(path: Path) -> Schedule:
"""Load and parse a schedule.yml file (raises ScheduleError)."""
path = Path(path)
if not path.exists():
raise ScheduleError(f"schedule file not found: {path}")
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc: # pragma: no cover - passthrough message
raise ScheduleError(f"invalid YAML in {path}: {exc}") from exc
return parse_schedule(raw, source_path=path)
def validate_schedule(
schedule: Schedule, known_agents: Optional[List[str]] = None
) -> List[str]:
"""Return a list of human-readable validation errors (empty == valid)."""
errors: List[str] = []
if schedule.version != SCHEDULE_VERSION:
errors.append(
f"unsupported version '{schedule.version}' "
f"(expected '{SCHEDULE_VERSION}')"
)
if not schedule.entries:
errors.append("no agents declared under 'agents:'")
seen: set = set()
known = set(known_agents) if known_agents is not None else None
for entry in schedule.entries:
if entry.agent in seen:
errors.append(f"duplicate agent entry: {entry.agent}")
seen.add(entry.agent)
if entry.cadence not in VALID_CADENCES:
errors.append(
f"agent '{entry.agent}': invalid cadence '{entry.cadence}' "
f"(expected one of {', '.join(VALID_CADENCES)})"
)
if known is not None and entry.agent not in known:
errors.append(
f"agent '{entry.agent}' is not an installed or packaged agent"
)
return errors
def default_schedule_yaml(timezone: str = DEFAULT_TIMEZONE) -> str:
"""Render the default schedule.yml scaffold for `schedule init`."""
document = {
"version": SCHEDULE_VERSION,
"timezone": timezone,
"agents": dict(DEFAULT_AGENTS),
}
header = (
"# Kaizen scheduled agent execution manifest (ADR-005)\n"
"# Declares which agents run on what cadence in this repo.\n"
"# Validate with: kaizen-agentic schedule validate\n"
)
body = yaml.safe_dump(document, sort_keys=False, default_flow_style=False)
return header + body