agent_harness -> rein_aharness (package + all imports), CLI command agent-harness -> rein-aharness, Docker image tag, k8s namespace/labels/ names, Makefile targets, deploy script env var/paths. In-repo identity strings (hub event source, metrics harness field, default assignee, argparse prog name, commit author identity) updated to match. Historical documents left untouched on purpose: docs/adr/ADR-001-agent-harness-architecture.md, docs/architecture.md (dated v0.1 snapshot), workplans/HARNESS-WP-0001 (completed under the old name), and the SSH host alias "forgejo-agent-harness" (external ~/.ssh/config entry, not owned here). Verified: 47/47 tests pass, CLI runs correctly from a fresh venv, `make image` builds and the resulting container runs correctly. deploy/README.md gained an explicit rename cutover checklist for what this session cannot safely do unattended -- moving the host-side secrets dir and checkout on railiance01, and not deleting the old k8s namespace until the new one is confirmed working. The actual live cutover (running that checklist against the real Railiance deployment) is not attempted here -- real production surgery on binky-control's live automation, needs the operator present. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""Instance manifest — declarative agent instances in consuming repos.
|
|
|
|
Extends ADR-005 `.kaizen/schedule.yml` with harness fields. kaizen-agentic
|
|
owns the base keys (version, timezone, agents.<name>.{cadence,cron,enabled});
|
|
rein-aharness owns the extension keys (lane, tool_profile, budget, harness,
|
|
optional blueprint). Same file — no sibling manifest unless kaizen owners
|
|
later prefer separation.
|
|
|
|
See docs/instance-manifest.md for the full contract.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from rein_aharness.profiles import PROFILES, UnknownToolProfileError, get_profile
|
|
|
|
MANIFEST_RELATIVE_PATH = Path(".kaizen") / "schedule.yml"
|
|
MANIFEST_VERSION = "1"
|
|
VALID_CADENCES = ("daily", "weekly", "monthly")
|
|
VALID_LANES = ("green", "blue")
|
|
# Package major this harness release implements (0.x → 0).
|
|
HARNESS_MAJOR = 0
|
|
|
|
|
|
class ManifestError(ValueError):
|
|
"""Structural failure loading or parsing a manifest."""
|
|
|
|
|
|
@dataclass
|
|
class AgentInstance:
|
|
"""One agent instance declared in a consuming repo."""
|
|
|
|
name: str
|
|
cadence: str
|
|
enabled: bool = True
|
|
cron: str | None = None
|
|
# Harness extension fields (optional in file; defaults applied at load).
|
|
blueprint: str | None = None # defaults to name
|
|
lane: str | None = None
|
|
tool_profile: str | None = None
|
|
budget: int | None = None # tokens per run; None = unlimited for this field
|
|
harness: int | None = None # pinned major; None inherits repo-level
|
|
|
|
@property
|
|
def blueprint_name(self) -> str:
|
|
return self.blueprint or self.name
|
|
|
|
|
|
@dataclass
|
|
class InstanceManifest:
|
|
"""Parsed instance manifest (schedule.yml + harness extensions)."""
|
|
|
|
version: str
|
|
timezone: str | None
|
|
harness: int | None # repo-level pinned harness major
|
|
agents: list[AgentInstance] = field(default_factory=list)
|
|
source_path: Path | None = None
|
|
|
|
def agent_for(self, name: str) -> AgentInstance | None:
|
|
for agent in self.agents:
|
|
if agent.name == name:
|
|
return agent
|
|
return None
|
|
|
|
def enabled_agents(self) -> list[AgentInstance]:
|
|
return [a for a in self.agents if a.enabled]
|
|
|
|
def effective_harness(self, agent: AgentInstance) -> int | None:
|
|
if agent.harness is not None:
|
|
return agent.harness
|
|
return self.harness
|
|
|
|
|
|
def manifest_path(project_root: Path) -> Path:
|
|
return Path(project_root) / MANIFEST_RELATIVE_PATH
|
|
|
|
|
|
def _parse_budget(raw: Any, agent_name: str) -> int | None:
|
|
if raw is None:
|
|
return None
|
|
if isinstance(raw, bool) or not isinstance(raw, int):
|
|
raise ManifestError(
|
|
f"agent '{agent_name}': budget must be a positive integer (tokens/run)"
|
|
)
|
|
if raw <= 0:
|
|
raise ManifestError(
|
|
f"agent '{agent_name}': budget must be a positive integer (tokens/run)"
|
|
)
|
|
return raw
|
|
|
|
|
|
def _parse_harness_major(raw: Any, context: str) -> int | None:
|
|
if raw is None:
|
|
return None
|
|
if isinstance(raw, bool) or not isinstance(raw, int):
|
|
raise ManifestError(f"{context}: harness must be a non-negative integer major")
|
|
if raw < 0:
|
|
raise ManifestError(f"{context}: harness must be a non-negative integer major")
|
|
return raw
|
|
|
|
|
|
def parse_manifest(data: Any, source_path: Path | None = None) -> InstanceManifest:
|
|
"""Parse a raw mapping into InstanceManifest. Structural errors raise."""
|
|
if not isinstance(data, dict):
|
|
raise ManifestError("manifest must be a YAML mapping at the top level")
|
|
|
|
version = data.get("version")
|
|
if version is None:
|
|
raise ManifestError("missing required key: version")
|
|
version = str(version)
|
|
|
|
timezone = data.get("timezone")
|
|
if timezone is not None and not isinstance(timezone, str):
|
|
raise ManifestError("timezone must be a string")
|
|
|
|
repo_harness = _parse_harness_major(data.get("harness"), "top-level")
|
|
|
|
agents_raw = data.get("agents", {})
|
|
if not isinstance(agents_raw, dict):
|
|
raise ManifestError("agents must be a mapping of agent-name -> settings")
|
|
|
|
agents: list[AgentInstance] = []
|
|
for name, settings in agents_raw.items():
|
|
if settings is None:
|
|
settings = {}
|
|
if not isinstance(settings, dict):
|
|
raise ManifestError(f"agent '{name}' settings must be a mapping")
|
|
|
|
cron = settings.get("cron")
|
|
if cron is not None and not isinstance(cron, str):
|
|
raise ManifestError(f"agent '{name}' cron must be a string")
|
|
|
|
blueprint = settings.get("blueprint")
|
|
if blueprint is not None and not isinstance(blueprint, str):
|
|
raise ManifestError(f"agent '{name}' blueprint must be a string")
|
|
|
|
lane = settings.get("lane")
|
|
if lane is not None and not isinstance(lane, str):
|
|
raise ManifestError(f"agent '{name}' lane must be a string")
|
|
|
|
tool_profile = settings.get("tool_profile")
|
|
if tool_profile is not None and not isinstance(tool_profile, str):
|
|
raise ManifestError(f"agent '{name}' tool_profile must be a string")
|
|
|
|
agents.append(
|
|
AgentInstance(
|
|
name=str(name),
|
|
cadence=str(settings.get("cadence", "")),
|
|
enabled=bool(settings.get("enabled", True)),
|
|
cron=cron,
|
|
blueprint=blueprint,
|
|
lane=lane,
|
|
tool_profile=tool_profile,
|
|
budget=_parse_budget(settings.get("budget"), str(name)),
|
|
harness=_parse_harness_major(
|
|
settings.get("harness"), f"agent '{name}'"
|
|
),
|
|
)
|
|
)
|
|
|
|
return InstanceManifest(
|
|
version=version,
|
|
timezone=timezone,
|
|
harness=repo_harness,
|
|
agents=agents,
|
|
source_path=source_path,
|
|
)
|
|
|
|
|
|
def load_manifest(path: Path | str) -> InstanceManifest:
|
|
path = Path(path)
|
|
if not path.exists():
|
|
raise ManifestError(f"manifest not found: {path}")
|
|
try:
|
|
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except yaml.YAMLError as exc:
|
|
raise ManifestError(f"invalid YAML in {path}: {exc}") from exc
|
|
return parse_manifest(raw, source_path=path)
|
|
|
|
|
|
def load_manifest_for_repo(project_root: Path | str) -> InstanceManifest:
|
|
return load_manifest(manifest_path(Path(project_root)))
|
|
|
|
|
|
def validate_manifest(
|
|
manifest: InstanceManifest,
|
|
*,
|
|
require_harness_fields: bool = False,
|
|
) -> list[str]:
|
|
"""Return human-readable validation errors (empty == valid).
|
|
|
|
When *require_harness_fields* is False (default), ADR-005-only manifests
|
|
are valid: harness extension fields are checked only when present. When
|
|
True, every enabled agent must declare lane, tool_profile, and an
|
|
effective harness pin (repo- or agent-level).
|
|
"""
|
|
errors: list[str] = []
|
|
|
|
if manifest.version != MANIFEST_VERSION:
|
|
errors.append(
|
|
f"unsupported version '{manifest.version}' "
|
|
f"(expected '{MANIFEST_VERSION}')"
|
|
)
|
|
|
|
if not manifest.agents:
|
|
errors.append("no agents declared under 'agents:'")
|
|
|
|
seen: set[str] = set()
|
|
for agent in manifest.agents:
|
|
if agent.name in seen:
|
|
errors.append(f"duplicate agent entry: {agent.name}")
|
|
seen.add(agent.name)
|
|
|
|
if agent.cadence not in VALID_CADENCES:
|
|
errors.append(
|
|
f"agent '{agent.name}': invalid cadence '{agent.cadence}' "
|
|
f"(expected one of {', '.join(VALID_CADENCES)})"
|
|
)
|
|
|
|
if agent.lane is not None and agent.lane not in VALID_LANES:
|
|
errors.append(
|
|
f"agent '{agent.name}': invalid lane '{agent.lane}' "
|
|
f"(expected one of {', '.join(VALID_LANES)})"
|
|
)
|
|
|
|
if agent.tool_profile is not None:
|
|
try:
|
|
profile = get_profile(agent.tool_profile)
|
|
except UnknownToolProfileError as exc:
|
|
errors.append(f"agent '{agent.name}': {exc}")
|
|
else:
|
|
if agent.lane is not None and agent.lane != profile.lane:
|
|
errors.append(
|
|
f"agent '{agent.name}': lane '{agent.lane}' does not "
|
|
f"match tool_profile '{profile.name}' (lane={profile.lane})"
|
|
)
|
|
|
|
if require_harness_fields and agent.enabled:
|
|
if agent.lane is None:
|
|
errors.append(
|
|
f"agent '{agent.name}': lane is required for harness runs "
|
|
f"(green|blue)"
|
|
)
|
|
if agent.tool_profile is None:
|
|
errors.append(
|
|
f"agent '{agent.name}': tool_profile is required for harness runs"
|
|
)
|
|
if manifest.effective_harness(agent) is None:
|
|
errors.append(
|
|
f"agent '{agent.name}': harness major pin required "
|
|
f"(set top-level harness: or agents.{agent.name}.harness)"
|
|
)
|
|
|
|
effective = manifest.effective_harness(agent)
|
|
if effective is not None and effective != HARNESS_MAJOR:
|
|
# Soft pin check: warn-style error so validate fails closed for
|
|
# mismatched majors (instances must upgrade deliberately).
|
|
errors.append(
|
|
f"agent '{agent.name}': pinned harness major {effective} "
|
|
f"does not match this runtime (major {HARNESS_MAJOR})"
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def resolve_run_policy(
|
|
project_root: Path | str,
|
|
agent_name: str,
|
|
*,
|
|
default_profile: str = "green-commit-only",
|
|
) -> tuple[str, int | None, str | None, str]:
|
|
"""Resolve (tool_profile_name, budget_tokens, lane, blueprint) for a run.
|
|
|
|
If the repo has no manifest or no entry for *agent_name*, returns the
|
|
default profile with no budget and blueprint=agent_name. If the entry
|
|
names an unknown profile, raises UnknownToolProfileError (refuse to run).
|
|
"""
|
|
root = Path(project_root)
|
|
path = manifest_path(root)
|
|
if not path.exists():
|
|
get_profile(default_profile) # validate default exists
|
|
return default_profile, None, None, agent_name
|
|
|
|
manifest = load_manifest(path)
|
|
instance = manifest.agent_for(agent_name)
|
|
if instance is None or not instance.enabled:
|
|
get_profile(default_profile)
|
|
return default_profile, None, None, agent_name
|
|
|
|
profile_name = instance.tool_profile or default_profile
|
|
get_profile(profile_name) # raises if unknown
|
|
return profile_name, instance.budget, instance.lane, instance.blueprint_name
|
|
|
|
|
|
def known_profile_names() -> list[str]:
|
|
return sorted(PROFILES)
|