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.
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: 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
|