rein-aharness/rein_aharness/intake.py
tegwick f6930ad115 Rename package, CLI, and deploy artifacts to rein-aharness (HARNESS-WP-0002-T02)
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>
2026-07-26 14:22:18 +02:00

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