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:
tegwick 2026-07-18 11:30:40 +02:00
parent 154e535695
commit 66ccd4fa01
7 changed files with 705 additions and 34 deletions

View file

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

View file

@ -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
View 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 namepath, 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

View file

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

View file

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