rein-aharness/agent_harness/intake.py
tegwick 66ccd4fa01 feat: issue-core task intake poll/claim/run (HARNESS-WP-0001-T03)
Add intake client mapping emissions to TaskSpec, CLI poll and
run --from-issue-core, keep --task-file for local dev. Completes
HARNESS-WP-0001 workplan.
2026-07-18 11:30:40 +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: agent-harness)
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 `agent-harness 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 agent_harness.taskspec import TaskSpec, TaskSpecError
DEFAULT_ISSUE_CORE_URL = "http://127.0.0.1:8765"
DEFAULT_INTAKE_LABELS = ("automated",)
DEFAULT_ASSIGNEE = "agent-harness"
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