"""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