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.
This commit is contained in:
parent
154e535695
commit
66ccd4fa01
7 changed files with 705 additions and 34 deletions
|
|
@ -21,9 +21,15 @@ agent-harness validate --target ~/binky-control --strict # require harness fie
|
|||
# List named tool profiles
|
||||
agent-harness profiles
|
||||
|
||||
# Run exactly one task (local JSON task-file path)
|
||||
# Run exactly one task (local JSON task-file path — dev)
|
||||
agent-harness run --task-file examples/task-hello-sandbox.json [--no-hub] [--no-metrics]
|
||||
|
||||
# Production intake: poll issue-core (activity-core emissions), claim, run, close
|
||||
export ISSUE_CORE_URL=http://127.0.0.1:8765 ISSUE_CORE_API_KEY=…
|
||||
agent-harness poll
|
||||
agent-harness run --from-issue-core
|
||||
|
||||
|
||||
# Deterministic mailbox scan (no LLM session)
|
||||
agent-harness mail-scan --target-repo ~/binky-control
|
||||
|
||||
|
|
|
|||
|
|
@ -68,12 +68,123 @@ def _cmd_profiles(_args: argparse.Namespace) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def _cmd_poll(args: argparse.Namespace) -> int:
|
||||
from agent_harness.intake import IntakeError, IssueCoreClient, poll_next
|
||||
|
||||
try:
|
||||
client = IssueCoreClient()
|
||||
result = poll_next(client, claim=not args.no_claim)
|
||||
except IntakeError as exc:
|
||||
print(f"intake error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if result is None:
|
||||
print(json.dumps({"queue": "empty"}, indent=2))
|
||||
return 0
|
||||
issue, spec = result
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"issue_id": issue.issue_id,
|
||||
"state": issue.state,
|
||||
"title": issue.title,
|
||||
"agent": spec.agent,
|
||||
"target_repo": str(spec.target_repo),
|
||||
"completion_event_type": spec.completion_event_type,
|
||||
"labels": spec.labels,
|
||||
"claimed": not args.no_claim,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_run(args: argparse.Namespace) -> int:
|
||||
from agent_harness.intake import IntakeError, IssueCoreClient, poll_next
|
||||
|
||||
issue_id: str | None = None
|
||||
client: IssueCoreClient | None = None
|
||||
|
||||
if args.from_issue_core:
|
||||
try:
|
||||
client = IssueCoreClient()
|
||||
polled = poll_next(client, claim=True)
|
||||
except IntakeError as exc:
|
||||
print(f"intake error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if polled is None:
|
||||
print(json.dumps({"ok": True, "queue": "empty"}, indent=2))
|
||||
return 0
|
||||
issue, spec = polled
|
||||
issue_id = issue.issue_id
|
||||
else:
|
||||
if not args.task_file:
|
||||
print(
|
||||
"error: provide --task-file or --from-issue-core",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
try:
|
||||
spec = TaskSpec.from_file(args.task_file)
|
||||
except (TaskSpecError, OSError, json.JSONDecodeError) as exc:
|
||||
print(f"invalid task spec: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = run_task(
|
||||
spec,
|
||||
report_to_hub=not args.no_hub,
|
||||
write_metrics=not args.no_metrics,
|
||||
)
|
||||
|
||||
closed = False
|
||||
close_error = ""
|
||||
if client is not None and issue_id:
|
||||
try:
|
||||
if result.ok:
|
||||
client.close(issue_id)
|
||||
closed = True
|
||||
else:
|
||||
client.reopen(issue_id)
|
||||
except IntakeError as exc:
|
||||
close_error = str(exc)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": result.ok,
|
||||
"committed": result.committed,
|
||||
"head_after": result.head_after,
|
||||
"persona_source": result.persona_source,
|
||||
"tool_profile": result.tool_profile,
|
||||
"budget_tokens": result.budget_tokens,
|
||||
"tokens_spent": result.tokens_spent,
|
||||
"execution_time_s": round(result.execution_time_s, 3),
|
||||
"reason": result.reason,
|
||||
"issue_id": issue_id,
|
||||
"issue_closed": closed,
|
||||
"issue_close_error": close_error,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0 if result.ok else 1
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="agent-harness")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
run = sub.add_parser("run", help="Execute one task from a JSON spec file")
|
||||
run.add_argument("--task-file", required=True)
|
||||
run = sub.add_parser(
|
||||
"run",
|
||||
help="Execute one task from a JSON file or next issue-core emission",
|
||||
)
|
||||
run_src = run.add_mutually_exclusive_group(required=True)
|
||||
run_src.add_argument("--task-file", help="Local JSON task-spec (dev path)")
|
||||
run_src.add_argument(
|
||||
"--from-issue-core",
|
||||
action="store_true",
|
||||
help="Poll issue-core for one open harness-labeled task, claim, run, close",
|
||||
)
|
||||
run.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
|
||||
run.add_argument(
|
||||
"--no-metrics",
|
||||
|
|
@ -127,6 +238,16 @@ def main(argv: list[str] | None = None) -> int:
|
|||
smoke.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
|
||||
smoke.add_argument("--no-push", action="store_true", help="Skip git push after commit")
|
||||
|
||||
poll = sub.add_parser(
|
||||
"poll",
|
||||
help="Peek/claim next open issue-core task labeled for the harness (no execute)",
|
||||
)
|
||||
poll.add_argument(
|
||||
"--no-claim",
|
||||
action="store_true",
|
||||
help="List/map only; do not set in_progress",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "validate":
|
||||
|
|
@ -135,6 +256,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||
if args.command == "profiles":
|
||||
return _cmd_profiles(args)
|
||||
|
||||
if args.command == "poll":
|
||||
return _cmd_poll(args)
|
||||
|
||||
if args.command == "run":
|
||||
return _cmd_run(args)
|
||||
|
||||
if args.command == "smoke":
|
||||
from agent_harness.smoke import ensure_sandbox_clone, run_smoke
|
||||
|
||||
|
|
@ -201,34 +328,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||
)
|
||||
return 0 if result.ok else 1
|
||||
|
||||
try:
|
||||
spec = TaskSpec.from_file(args.task_file)
|
||||
except (TaskSpecError, OSError, json.JSONDecodeError) as exc:
|
||||
print(f"invalid task spec: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = run_task(
|
||||
spec,
|
||||
report_to_hub=not args.no_hub,
|
||||
write_metrics=not args.no_metrics,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": result.ok,
|
||||
"committed": result.committed,
|
||||
"head_after": result.head_after,
|
||||
"persona_source": result.persona_source,
|
||||
"tool_profile": result.tool_profile,
|
||||
"budget_tokens": result.budget_tokens,
|
||||
"tokens_spent": result.tokens_spent,
|
||||
"execution_time_s": round(result.execution_time_s, 3),
|
||||
"reason": result.reason,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0 if result.ok else 1
|
||||
print(f"unknown command: {args.command}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
287
agent_harness/intake.py
Normal file
287
agent_harness/intake.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"""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
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
|---|---|---|---|
|
||||
| Instance manifest | `manifest.py` | extends `.kaizen/schedule.yml`; `validate` CLI | unchanged contract; tenant onboarding |
|
||||
| Tool profiles | `profiles.py` | `green-commit-only`, `blue-mail-triage` registry | additional named profiles as needed |
|
||||
| Task intake | `taskspec.py` | JSON task-spec file | poll issue-core sink / TaskExecutorWorkflow |
|
||||
| Task intake | `intake.py` + `taskspec.py` | issue-core GET/PATCH poll+claim; JSON task-file for local dev | NATS when activity-core migrates |
|
||||
| Persona | `persona.py` | `kaizen-agentic schedule prepare` (ADR-005) | unchanged, plus phase-memory profile hook |
|
||||
| Session | `adapter.py` | `AgenticClaudeCodeAdapter` (cwd-pinned, profile allow-list) | + hosted adapters |
|
||||
| Orchestration | `runner.py` | profile/budget → session → commit → metrics → hub | activity-core intake |
|
||||
|
|
|
|||
73
docs/task-intake.md
Normal file
73
docs/task-intake.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Task intake (HARNESS-WP-0001-T03)
|
||||
|
||||
activity-core emits tasks via IssueSink → `POST /issues/` on **issue-core**.
|
||||
agent-harness **consumes** them by polling the same service:
|
||||
|
||||
```
|
||||
activity-core (cron/rule)
|
||||
→ IssueCoreRestSink POST /issues/
|
||||
→ issue-core backend (sqlite / gitea)
|
||||
→ agent-harness poll/claim GET+PATCH /issues/
|
||||
→ run_task (profile + budget + persona + session)
|
||||
→ PATCH close + hub progress + .kaizen/metrics
|
||||
```
|
||||
|
||||
`TaskExecutorWorkflow` stays a stub (activity-core INTENT); execution lives here.
|
||||
|
||||
## issue-core worker API
|
||||
|
||||
| Method | Path | Role |
|
||||
|--------|------|------|
|
||||
| POST | `/issues/` | Ingest (activity-core) |
|
||||
| GET | `/issues/?state=open&label=automated` | Poll queue |
|
||||
| GET | `/issues/{id}` | Fetch one |
|
||||
| PATCH | `/issues/{id}` | Claim (`in_progress` + assignee) or `closed` |
|
||||
|
||||
Auth: `Authorization: Bearer $ISSUE_CORE_API_KEY` or `X-API-Key`.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
export ISSUE_CORE_URL=http://127.0.0.1:8765 # or in-cluster service
|
||||
export ISSUE_CORE_API_KEY=…
|
||||
export AGENT_HARNESS_REPO_MAP='{"binky-control":"/home/tegwick/binky-control"}'
|
||||
|
||||
# Peek / claim without executing
|
||||
agent-harness poll
|
||||
agent-harness poll --no-claim
|
||||
|
||||
# Claim → run → close (or reopen on failure)
|
||||
agent-harness run --from-issue-core
|
||||
|
||||
# Local dev (unchanged)
|
||||
agent-harness run --task-file examples/task-hello-sandbox.json --no-hub
|
||||
```
|
||||
|
||||
## Label filter
|
||||
|
||||
Default: issues must carry the **`automated`** label (matches activity-core
|
||||
binky definitions). Override:
|
||||
|
||||
```bash
|
||||
export AGENT_HARNESS_INTAKE_LABELS=automated,harness # ALL required
|
||||
```
|
||||
|
||||
## Agent + completion event mapping
|
||||
|
||||
From `activity_definition_id` or labels:
|
||||
|
||||
| Hint | Instance agent | completion_event_type |
|
||||
|------|----------------|------------------------|
|
||||
| `binky-daily-rhythm` / `rhythm` | `coach` | `binky_daily_brief` |
|
||||
| `binky-weekly-mail-intake` / `mail-intake` | `mail-triage` | `binky_mail_intake` |
|
||||
| `binky-weekly-review-prep` / `weekly-review` | `review-prep` | `binky_weekly_review` |
|
||||
| else | `coach` | `executor_run` |
|
||||
|
||||
Instance policy (`tool_profile`, `budget`, `lane`) still comes from the
|
||||
target repo's `.kaizen/schedule.yml`.
|
||||
|
||||
## Repo path resolution
|
||||
|
||||
1. Absolute/expanded path if it is already a git repo
|
||||
2. `AGENT_HARNESS_REPO_MAP` JSON
|
||||
3. Search `AGENT_HARNESS_REPO_ROOTS` (default `~:~/work`) for the slug
|
||||
204
tests/test_intake.py
Normal file
204
tests/test_intake.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_harness.intake import (
|
||||
EmittedIssue,
|
||||
IntakeConfig,
|
||||
IssueCoreClient,
|
||||
infer_agent_and_event,
|
||||
issue_to_taskspec,
|
||||
poll_next,
|
||||
resolve_target_repo,
|
||||
)
|
||||
from agent_harness.taskspec import TaskSpecError
|
||||
|
||||
|
||||
def _make_repo(tmp_path: Path, name: str = "binky-control") -> Path:
|
||||
repo = tmp_path / name
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
(repo / "README.md").write_text("repo\n")
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
)
|
||||
return repo
|
||||
|
||||
|
||||
def test_resolve_target_repo_by_slug(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path, "binky-control")
|
||||
found = resolve_target_repo(
|
||||
"binky-control",
|
||||
repo_roots=(str(tmp_path),),
|
||||
)
|
||||
assert found == repo.resolve()
|
||||
|
||||
|
||||
def test_resolve_target_repo_map(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path, "sandbox")
|
||||
found = resolve_target_repo(
|
||||
"coulomb/other-name",
|
||||
repo_map={"other-name": str(repo)},
|
||||
)
|
||||
assert found == repo.resolve()
|
||||
|
||||
|
||||
def test_infer_agent_from_definition() -> None:
|
||||
issue = EmittedIssue(
|
||||
issue_id="1",
|
||||
title="x",
|
||||
description="y",
|
||||
labels=["binky", "automated"],
|
||||
activity_definition_id="binky-weekly-mail-intake",
|
||||
)
|
||||
agent, event = infer_agent_and_event(issue)
|
||||
assert agent == "mail-triage"
|
||||
assert event == "binky_mail_intake"
|
||||
|
||||
|
||||
def test_infer_agent_from_labels() -> None:
|
||||
issue = EmittedIssue(
|
||||
issue_id="1",
|
||||
title="x",
|
||||
description="y",
|
||||
labels=["rhythm", "automated"],
|
||||
)
|
||||
agent, event = infer_agent_and_event(issue)
|
||||
assert agent == "coach"
|
||||
assert event == "binky_daily_brief"
|
||||
|
||||
|
||||
def test_issue_to_taskspec(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
issue = EmittedIssue(
|
||||
issue_id="abc",
|
||||
title="Run daily",
|
||||
description="do the rhythm",
|
||||
labels=["binky", "rhythm", "automated"],
|
||||
target_repo="binky-control",
|
||||
activity_definition_id="binky-daily-rhythm",
|
||||
)
|
||||
cfg = IntakeConfig(repo_roots=(str(tmp_path),))
|
||||
spec = issue_to_taskspec(issue, cfg)
|
||||
assert spec.title == "Run daily"
|
||||
assert spec.agent == "coach"
|
||||
assert spec.completion_event_type == "binky_daily_brief"
|
||||
assert spec.target_repo == repo.resolve()
|
||||
|
||||
|
||||
def test_issue_to_taskspec_missing_repo_raises(tmp_path: Path) -> None:
|
||||
issue = EmittedIssue(
|
||||
issue_id="1",
|
||||
title="x",
|
||||
description="y",
|
||||
target_repo="no-such-repo",
|
||||
)
|
||||
with pytest.raises(TaskSpecError):
|
||||
issue_to_taskspec(issue, IntakeConfig(repo_roots=(str(tmp_path),)))
|
||||
|
||||
|
||||
def test_client_list_claim_close() -> None:
|
||||
cfg = IntakeConfig(
|
||||
base_url="http://issue-core.test",
|
||||
api_key="k",
|
||||
require_labels=("automated",),
|
||||
assignee="agent-harness",
|
||||
)
|
||||
client = IssueCoreClient(cfg)
|
||||
|
||||
list_body = [
|
||||
{
|
||||
"issue_id": "id-1",
|
||||
"number": 3,
|
||||
"title": "Run Binky daily rhythm",
|
||||
"description": "hygiene + brief",
|
||||
"state": "open",
|
||||
"labels": ["binky", "rhythm", "automated"],
|
||||
"target_repo": "binky-control",
|
||||
"activity_definition_id": "binky-daily-rhythm",
|
||||
}
|
||||
]
|
||||
claim_body = {**list_body[0], "state": "in_progress", "assignee": "agent-harness"}
|
||||
close_body = {**claim_body, "state": "closed"}
|
||||
|
||||
with patch("agent_harness.intake.httpx.get") as get_mock, patch(
|
||||
"agent_harness.intake.httpx.patch"
|
||||
) as patch_mock:
|
||||
get_resp = MagicMock()
|
||||
get_resp.raise_for_status = MagicMock()
|
||||
get_resp.json.return_value = list_body
|
||||
get_mock.return_value = get_resp
|
||||
|
||||
issues = client.list_open()
|
||||
assert len(issues) == 1
|
||||
assert issues[0].issue_id == "id-1"
|
||||
get_mock.assert_called_once()
|
||||
params = get_mock.call_args.kwargs.get("params") or get_mock.call_args[1].get(
|
||||
"params"
|
||||
)
|
||||
assert ("label", "automated") in params
|
||||
|
||||
patch_resp = MagicMock()
|
||||
patch_resp.raise_for_status = MagicMock()
|
||||
patch_resp.json.return_value = claim_body
|
||||
patch_mock.return_value = patch_resp
|
||||
claimed = client.claim("id-1")
|
||||
assert claimed.state == "in_progress"
|
||||
|
||||
patch_resp.json.return_value = close_body
|
||||
closed = client.close("id-1")
|
||||
assert closed.state == "closed"
|
||||
|
||||
|
||||
def test_poll_next_empty() -> None:
|
||||
client = IssueCoreClient(IntakeConfig(base_url="http://x", api_key="k"))
|
||||
with patch.object(client, "list_open", return_value=[]):
|
||||
assert poll_next(client) is None
|
||||
|
||||
|
||||
def test_poll_next_claims_and_maps(tmp_path: Path) -> None:
|
||||
_make_repo(tmp_path)
|
||||
client = IssueCoreClient(
|
||||
IntakeConfig(
|
||||
base_url="http://x",
|
||||
api_key="k",
|
||||
repo_roots=(str(tmp_path),),
|
||||
)
|
||||
)
|
||||
open_issue = EmittedIssue(
|
||||
issue_id="id-9",
|
||||
title="Mail intake",
|
||||
description="triage",
|
||||
labels=["mail-intake", "automated"],
|
||||
target_repo="binky-control",
|
||||
activity_definition_id="binky-weekly-mail-intake",
|
||||
number=9,
|
||||
)
|
||||
claimed = EmittedIssue(
|
||||
issue_id="id-9",
|
||||
title="Mail intake",
|
||||
description="triage",
|
||||
labels=["mail-intake", "automated"],
|
||||
target_repo="binky-control",
|
||||
activity_definition_id="binky-weekly-mail-intake",
|
||||
number=9,
|
||||
state="in_progress",
|
||||
)
|
||||
with patch.object(client, "list_open", return_value=[open_issue]), patch.object(
|
||||
client, "claim", return_value=claimed
|
||||
) as claim:
|
||||
result = poll_next(client, claim=True)
|
||||
assert result is not None
|
||||
issue, spec = result
|
||||
claim.assert_called_once_with("id-9")
|
||||
assert issue.state == "in_progress"
|
||||
assert spec.agent == "mail-triage"
|
||||
assert spec.completion_event_type == "binky_mail_intake"
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
id: HARNESS-WP-0001
|
||||
title: "Harness Foundation: from prototype to shared runtime"
|
||||
status: active
|
||||
status: done
|
||||
state_hub_workstream_id: "1509b40c-94c9-4a52-8661-22ca05979bca"
|
||||
---
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ development.
|
|||
|
||||
```task
|
||||
id: HARNESS-WP-0001-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "4970daca-2553-4bc6-9ab2-74e172695083"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue