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>
287 lines
9.9 KiB
Python
287 lines
9.9 KiB
Python
"""Task intake from issue-core REST (activity-core emission sink).
|
|
|
|
Polls open issues labeled for the harness, maps them to TaskSpec, claims
|
|
them (in_progress + assignee), and after a successful run closes them.
|
|
|
|
Environment:
|
|
|
|
ISSUE_CORE_URL default http://127.0.0.1:8765
|
|
ISSUE_CORE_API_KEY required for live poll
|
|
AGENT_HARNESS_INTAKE_LABELS comma list; issue must have ALL (default: automated)
|
|
AGENT_HARNESS_ASSIGNEE claim assignee (default: rein-aharness)
|
|
AGENT_HARNESS_REPO_MAP JSON object name→path, e.g. {"binky-control":"~/binky-control"}
|
|
AGENT_HARNESS_REPO_ROOTS colon-separated search roots (default: ~:~/work)
|
|
|
|
Local development still uses `rein-aharness run --task-file …`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from rein_aharness.taskspec import TaskSpec, TaskSpecError
|
|
|
|
DEFAULT_ISSUE_CORE_URL = "http://127.0.0.1:8765"
|
|
DEFAULT_INTAKE_LABELS = ("automated",)
|
|
DEFAULT_ASSIGNEE = "rein-aharness"
|
|
DEFAULT_REPO_ROOTS = ("~", "~/work")
|
|
|
|
# activity-definition / label → (agent instance, completion_event_type)
|
|
_DEFINITION_HINTS: dict[str, tuple[str, str]] = {
|
|
"binky-daily-rhythm": ("coach", "binky_daily_brief"),
|
|
"binky-weekly-mail-intake": ("mail-triage", "binky_mail_intake"),
|
|
"binky-weekly-review-prep": ("review-prep", "binky_weekly_review"),
|
|
"daily_brief": ("coach", "binky_daily_brief"),
|
|
"mail_intake": ("mail-triage", "binky_mail_intake"),
|
|
"weekly_review": ("review-prep", "binky_weekly_review"),
|
|
"rhythm": ("coach", "binky_daily_brief"),
|
|
"mail-intake": ("mail-triage", "binky_mail_intake"),
|
|
"weekly-review": ("review-prep", "binky_weekly_review"),
|
|
}
|
|
|
|
|
|
class IntakeError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class EmittedIssue:
|
|
"""Normalized issue-core issue for harness consumption."""
|
|
|
|
issue_id: str
|
|
title: str
|
|
description: str
|
|
labels: list[str] = field(default_factory=list)
|
|
target_repo: str | None = None
|
|
priority: str | None = None
|
|
activity_definition_id: str | None = None
|
|
source_id: str | None = None
|
|
triggering_event_id: str | None = None
|
|
state: str = "open"
|
|
number: int = 0
|
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
|
|
@classmethod
|
|
def from_api(cls, data: dict[str, Any]) -> "EmittedIssue":
|
|
return cls(
|
|
issue_id=str(data.get("issue_id") or data.get("id") or ""),
|
|
title=str(data.get("title") or ""),
|
|
description=str(data.get("description") or ""),
|
|
labels=[str(x) for x in (data.get("labels") or [])],
|
|
target_repo=data.get("target_repo"),
|
|
priority=data.get("priority"),
|
|
activity_definition_id=data.get("activity_definition_id"),
|
|
source_id=data.get("source_id"),
|
|
triggering_event_id=data.get("triggering_event_id"),
|
|
state=str(data.get("state") or "open"),
|
|
number=int(data.get("number") or 0),
|
|
raw=data,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class IntakeConfig:
|
|
base_url: str = DEFAULT_ISSUE_CORE_URL
|
|
api_key: str = ""
|
|
require_labels: tuple[str, ...] = DEFAULT_INTAKE_LABELS
|
|
assignee: str = DEFAULT_ASSIGNEE
|
|
repo_map: dict[str, str] = field(default_factory=dict)
|
|
repo_roots: tuple[str, ...] = DEFAULT_REPO_ROOTS
|
|
timeout: float = 15.0
|
|
|
|
@classmethod
|
|
def from_env(cls) -> "IntakeConfig":
|
|
labels_raw = os.environ.get("AGENT_HARNESS_INTAKE_LABELS", "automated")
|
|
labels = tuple(part.strip() for part in labels_raw.split(",") if part.strip())
|
|
roots_raw = os.environ.get("AGENT_HARNESS_REPO_ROOTS", "~:~/work")
|
|
roots = tuple(part.strip() for part in roots_raw.split(":") if part.strip())
|
|
repo_map: dict[str, str] = {}
|
|
map_raw = os.environ.get("AGENT_HARNESS_REPO_MAP", "").strip()
|
|
if map_raw:
|
|
repo_map = {str(k): str(v) for k, v in json.loads(map_raw).items()}
|
|
return cls(
|
|
base_url=os.environ.get("ISSUE_CORE_URL", DEFAULT_ISSUE_CORE_URL).rstrip("/"),
|
|
api_key=os.environ.get("ISSUE_CORE_API_KEY", "").strip(),
|
|
require_labels=labels or DEFAULT_INTAKE_LABELS,
|
|
assignee=os.environ.get("AGENT_HARNESS_ASSIGNEE", DEFAULT_ASSIGNEE),
|
|
repo_map=repo_map,
|
|
repo_roots=roots or DEFAULT_REPO_ROOTS,
|
|
)
|
|
|
|
|
|
class IssueCoreClient:
|
|
"""Thin REST client for issue-core poll/claim/close."""
|
|
|
|
def __init__(self, config: IntakeConfig | None = None):
|
|
self.config = config or IntakeConfig.from_env()
|
|
|
|
def _headers(self) -> dict[str, str]:
|
|
if not self.config.api_key:
|
|
raise IntakeError(
|
|
"ISSUE_CORE_API_KEY is not set (required to poll issue-core)"
|
|
)
|
|
return {
|
|
"Authorization": f"Bearer {self.config.api_key}",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
def list_open(
|
|
self,
|
|
*,
|
|
labels: tuple[str, ...] | None = None,
|
|
limit: int = 50,
|
|
) -> list[EmittedIssue]:
|
|
want = labels if labels is not None else self.config.require_labels
|
|
params: list[tuple[str, str]] = [("state", "open"), ("limit", str(limit))]
|
|
for lab in want:
|
|
params.append(("label", lab))
|
|
try:
|
|
resp = httpx.get(
|
|
f"{self.config.base_url}/issues/",
|
|
params=params,
|
|
headers=self._headers(),
|
|
timeout=self.config.timeout,
|
|
)
|
|
resp.raise_for_status()
|
|
except httpx.HTTPError as exc:
|
|
raise IntakeError(f"list issues failed: {exc}") from exc
|
|
data = resp.json()
|
|
if not isinstance(data, list):
|
|
raise IntakeError(f"unexpected list payload type: {type(data)}")
|
|
return [EmittedIssue.from_api(item) for item in data]
|
|
|
|
def claim(self, issue_id: str) -> EmittedIssue:
|
|
return self._patch(
|
|
issue_id,
|
|
{"state": "in_progress", "assignee": self.config.assignee},
|
|
)
|
|
|
|
def close(self, issue_id: str) -> EmittedIssue:
|
|
return self._patch(issue_id, {"state": "closed"})
|
|
|
|
def reopen(self, issue_id: str) -> EmittedIssue:
|
|
return self._patch(issue_id, {"state": "open", "assignee": ""})
|
|
|
|
def _patch(self, issue_id: str, body: dict[str, Any]) -> EmittedIssue:
|
|
try:
|
|
resp = httpx.patch(
|
|
f"{self.config.base_url}/issues/{issue_id}",
|
|
json=body,
|
|
headers=self._headers(),
|
|
timeout=self.config.timeout,
|
|
)
|
|
resp.raise_for_status()
|
|
except httpx.HTTPError as exc:
|
|
raise IntakeError(f"patch issue {issue_id} failed: {exc}") from exc
|
|
return EmittedIssue.from_api(resp.json())
|
|
|
|
|
|
def resolve_target_repo(
|
|
name: str,
|
|
*,
|
|
repo_map: dict[str, str] | None = None,
|
|
repo_roots: tuple[str, ...] = DEFAULT_REPO_ROOTS,
|
|
) -> Path:
|
|
"""Map emission target_repo slug/path to a local git checkout."""
|
|
raw = (name or "").strip()
|
|
if not raw:
|
|
raise TaskSpecError("target_repo is empty")
|
|
|
|
# Absolute or explicit path
|
|
direct = Path(raw).expanduser()
|
|
if direct.is_dir() and (direct / ".git").is_dir():
|
|
return direct.resolve()
|
|
|
|
# Strip org prefix: coulomb/binky-control → binky-control
|
|
slug = raw.split("/")[-1]
|
|
|
|
mapping = repo_map or {}
|
|
if slug in mapping:
|
|
path = Path(mapping[slug]).expanduser()
|
|
if (path / ".git").is_dir():
|
|
return path.resolve()
|
|
raise TaskSpecError(f"mapped target_repo not a git repo: {path}")
|
|
if raw in mapping:
|
|
path = Path(mapping[raw]).expanduser()
|
|
if (path / ".git").is_dir():
|
|
return path.resolve()
|
|
|
|
for root in repo_roots:
|
|
candidate = Path(root).expanduser() / slug
|
|
if (candidate / ".git").is_dir():
|
|
return candidate.resolve()
|
|
|
|
raise TaskSpecError(
|
|
f"cannot resolve target_repo '{name}' under {list(repo_roots)}; "
|
|
"set AGENT_HARNESS_REPO_MAP or clone the repo"
|
|
)
|
|
|
|
|
|
def infer_agent_and_event(issue: EmittedIssue) -> tuple[str, str]:
|
|
"""Return (agent_instance_name, completion_event_type)."""
|
|
keys: list[str] = []
|
|
if issue.activity_definition_id:
|
|
keys.append(issue.activity_definition_id)
|
|
keys.extend(issue.labels)
|
|
blob = " ".join(keys).lower()
|
|
for hint, mapped in _DEFINITION_HINTS.items():
|
|
if hint.lower() in blob:
|
|
return mapped
|
|
# explicit agent:name label
|
|
for lab in issue.labels:
|
|
if lab.startswith("agent:"):
|
|
return lab.split(":", 1)[1], "executor_run"
|
|
return "coach", "executor_run"
|
|
|
|
|
|
def issue_to_taskspec(issue: EmittedIssue, config: IntakeConfig | None = None) -> TaskSpec:
|
|
cfg = config or IntakeConfig.from_env()
|
|
if not issue.target_repo:
|
|
raise TaskSpecError(
|
|
f"issue {issue.issue_id} missing target_repo (ingestion metadata)"
|
|
)
|
|
target = resolve_target_repo(
|
|
issue.target_repo,
|
|
repo_map=cfg.repo_map,
|
|
repo_roots=cfg.repo_roots,
|
|
)
|
|
agent, event = infer_agent_and_event(issue)
|
|
timeout = 900
|
|
return TaskSpec(
|
|
title=issue.title,
|
|
description=issue.description,
|
|
target_repo=target,
|
|
agent=agent,
|
|
labels=list(issue.labels),
|
|
hub_task_id=None,
|
|
completion_event_type=event,
|
|
timeout_seconds=timeout,
|
|
)
|
|
|
|
|
|
def poll_next(
|
|
client: IssueCoreClient | None = None,
|
|
*,
|
|
claim: bool = True,
|
|
) -> tuple[EmittedIssue, TaskSpec] | None:
|
|
"""Poll one open harness-labeled issue; optionally claim it.
|
|
|
|
Returns None when the queue is empty.
|
|
"""
|
|
client = client or IssueCoreClient()
|
|
issues = client.list_open()
|
|
if not issues:
|
|
return None
|
|
# Prefer oldest by number when available
|
|
issues_sorted = sorted(issues, key=lambda i: (i.number or 0, i.issue_id))
|
|
issue = issues_sorted[0]
|
|
if claim:
|
|
issue = client.claim(issue.issue_id)
|
|
spec = issue_to_taskspec(issue, client.config)
|
|
return issue, spec
|