Implement REIN-A-0002 ops_run claim loop and approach registry.

Add activity-core ops_run client, approach selection (FI/Binky/mail/agent),
claim-loop worker with lease heartbeat, CLI run --from-ops-run and claim-loop,
install units, and docs demoting issue-core to legacy external tickets.
T05 timer cutover remains operator after five clean cycles.
This commit is contained in:
tegwick 2026-08-03 20:04:25 +02:00
parent 9644202eb2
commit 8200a672ea
15 changed files with 1807 additions and 73 deletions

92
tests/test_approaches.py Normal file
View file

@ -0,0 +1,92 @@
"""Pure approach selection tests (REIN-A-0002-T02)."""
from __future__ import annotations
from rein_aharness.approaches import (
APPROACH_AGENT_SESSION,
APPROACH_BRIEF_DAILY,
APPROACH_FI_RESEARCH_BRIEF,
APPROACH_MAIL_PIPELINE,
APPROACH_UNMATCHED,
select_approach,
)
from rein_aharness.ops_run_client import OpsRun
def _run(**kwargs) -> OpsRun:
base = dict(
id="r",
activity_definition_id="",
idempotency_key="k",
target_repo="x",
title="",
description="",
labels=[],
)
base.update(kwargs)
return OpsRun(**base)
def test_select_fi_by_label() -> None:
assert (
select_approach(
_run(labels=["automated", "research-brief", "freedom-intelligence"])
)
== APPROACH_FI_RESEARCH_BRIEF
)
def test_select_fi_by_blob() -> None:
assert (
select_approach(
_run(
labels=["automated"],
activity_definition_id="fi-daily-research-brief",
title="FI daily research brief",
)
)
== APPROACH_FI_RESEARCH_BRIEF
)
def test_select_binky_rhythm() -> None:
assert (
select_approach(_run(labels=["binky", "rhythm", "automated"]))
== APPROACH_BRIEF_DAILY
)
def test_select_mail_intake() -> None:
assert (
select_approach(_run(labels=["mail-intake", "automated"]))
== APPROACH_MAIL_PIPELINE
)
def test_select_agent_session() -> None:
assert (
select_approach(_run(labels=["agent-session", "automated"]))
== APPROACH_AGENT_SESSION
)
def test_select_hint_overrides() -> None:
assert (
select_approach(
_run(labels=["automated"], approach_hint="fi-research-brief")
)
== APPROACH_FI_RESEARCH_BRIEF
)
def test_unmatched() -> None:
assert select_approach(_run(labels=["automated"], title="misc hygiene")) == (
APPROACH_UNMATCHED
)
def test_fi_before_generic_automated() -> None:
"""research-brief must not fall through to unmatched when only automated."""
assert (
select_approach(_run(labels=["research-brief"])) == APPROACH_FI_RESEARCH_BRIEF
)

97
tests/test_claim_loop.py Normal file
View file

@ -0,0 +1,97 @@
"""Claim loop process_one tests (REIN-A-0002-T03)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from rein_aharness.approaches import ApproachResult, APPROACH_FI_RESEARCH_BRIEF
from rein_aharness.claim_loop import process_one, poll_peek
from rein_aharness.ops_run_client import OpsRun, OpsRunConfig, ActivityCoreOpsClient
def _claimed_run() -> OpsRun:
return OpsRun(
id="run-1",
activity_definition_id="def",
idempotency_key="k",
target_repo="freedom-intelligence",
title="FI daily",
description="d",
labels=["automated", "research-brief"],
state="claimed",
claim_owner="worker-1",
attempt=1,
)
def test_process_one_empty() -> None:
client = MagicMock(spec=ActivityCoreOpsClient)
client.config = OpsRunConfig(worker_id="w", lease_seconds=90)
client.claim.return_value = []
r = process_one(client)
assert r.empty is True
assert r.claimed is False
def test_process_one_success_completes() -> None:
client = MagicMock(spec=ActivityCoreOpsClient)
client.config = OpsRunConfig(worker_id="w", lease_seconds=90)
client.claim.return_value = [_claimed_run()]
client.complete.return_value = OpsRun(
id="run-1",
activity_definition_id="def",
idempotency_key="k",
target_repo="freedom-intelligence",
title="FI daily",
description="",
state="succeeded",
labels=["automated", "research-brief"],
)
ar = ApproachResult(
ok=True,
approach=APPROACH_FI_RESEARCH_BRIEF,
result={"path": "briefs/x.md"},
reason="ok",
)
with patch("rein_aharness.claim_loop.execute_approach", return_value=ar):
r = process_one(client)
assert r.claimed is True
assert r.ok is True
assert r.approach == APPROACH_FI_RESEARCH_BRIEF
client.complete.assert_called_once()
client.fail.assert_not_called()
def test_process_one_failure_reopens() -> None:
client = MagicMock(spec=ActivityCoreOpsClient)
client.config = OpsRunConfig(worker_id="w", lease_seconds=90)
client.claim.return_value = [_claimed_run()]
client.fail.return_value = OpsRun(
id="run-1",
activity_definition_id="def",
idempotency_key="k",
target_repo="freedom-intelligence",
title="t",
description="",
state="open",
labels=["automated", "research-brief"],
)
ar = ApproachResult(
ok=False,
approach=APPROACH_FI_RESEARCH_BRIEF,
reason="llm timeout",
reopen=True,
)
with patch("rein_aharness.claim_loop.execute_approach", return_value=ar):
r = process_one(client)
assert r.ok is False
client.fail.assert_called_once()
assert client.fail.call_args.kwargs["reopen"] is True
def test_poll_peek() -> None:
client = MagicMock(spec=ActivityCoreOpsClient)
client.list_open.return_value = [_claimed_run()]
rows = poll_peek(client)
assert len(rows) == 1
assert rows[0]["approach"] == APPROACH_FI_RESEARCH_BRIEF

View file

@ -0,0 +1,168 @@
"""Tests for activity-core ops_run client (REIN-A-0002-T01)."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import httpx
import pytest
from rein_aharness.ops_run_client import (
ActivityCoreOpsClient,
OpsRun,
OpsRunConfig,
OpsRunError,
ops_run_to_taskspec,
)
from rein_aharness.taskspec import TaskSpecError
def test_ops_run_from_api() -> None:
row = OpsRun.from_api(
{
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"activity_definition_id": "11111111-2222-3333-4444-555555555555",
"idempotency_key": "k",
"target_repo": "freedom-intelligence",
"title": "FI daily",
"description": "d",
"labels": ["automated", "research-brief"],
"state": "open",
"attempt": 0,
}
)
assert row.target_repo == "freedom-intelligence"
assert "research-brief" in row.labels
def test_config_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ACTIVITY_CORE_URL", "http://actcore:8010/")
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "tok")
monkeypatch.setenv("AGENT_HARNESS_WORKER_ID", "w@host")
monkeypatch.setenv("AGENT_HARNESS_OPS_LABELS", "automated,research-brief")
monkeypatch.setenv(
"AGENT_HARNESS_REPO_MAP",
json.dumps({"freedom-intelligence": "/tmp/fi"}),
)
cfg = OpsRunConfig.from_env()
assert cfg.base_url == "http://actcore:8010"
assert cfg.worker_token == "tok"
assert cfg.worker_id == "w@host"
assert "research-brief" in cfg.claim_labels
assert cfg.repo_map["freedom-intelligence"] == "/tmp/fi"
def test_claim_posts_body() -> None:
cfg = OpsRunConfig(
base_url="http://example.test",
worker_token="secret",
worker_id="worker-1",
claim_labels=("automated",),
lease_seconds=120,
)
client = ActivityCoreOpsClient(cfg)
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"items": [
{
"id": "r1",
"activity_definition_id": "d1",
"idempotency_key": "k",
"target_repo": "freedom-intelligence",
"title": "t",
"description": "",
"labels": ["automated", "research-brief"],
"state": "claimed",
"claim_owner": "worker-1",
"attempt": 1,
}
]
}
with patch("rein_aharness.ops_run_client.httpx.post", return_value=mock_resp) as post:
claimed = client.claim(limit=1)
assert len(claimed) == 1
assert claimed[0].id == "r1"
assert claimed[0].state == "claimed"
kwargs = post.call_args.kwargs
assert kwargs["json"]["worker_id"] == "worker-1"
assert kwargs["json"]["labels"] == ["automated"]
assert kwargs["headers"]["X-Worker-Token"] == "secret"
def test_complete_and_fail() -> None:
cfg = OpsRunConfig(base_url="http://example.test", worker_id="w")
client = ActivityCoreOpsClient(cfg)
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"id": "r1",
"activity_definition_id": "d",
"idempotency_key": "k",
"title": "t",
"description": "",
"state": "succeeded",
"labels": [],
}
with patch("rein_aharness.ops_run_client.httpx.post", return_value=mock_resp):
out = client.complete("r1", result={"path": "x"})
assert out.state == "succeeded"
mock_resp.json.return_value = {**mock_resp.json.return_value, "state": "open"}
with patch("rein_aharness.ops_run_client.httpx.post", return_value=mock_resp) as post:
out = client.fail("r1", error="timeout", reopen=True)
assert post.call_args.kwargs["json"]["reopen"] is True
def test_claim_http_error() -> None:
cfg = OpsRunConfig(base_url="http://example.test", worker_id="w")
client = ActivityCoreOpsClient(cfg)
with patch(
"rein_aharness.ops_run_client.httpx.post",
side_effect=httpx.ConnectError("down"),
):
with pytest.raises(OpsRunError, match="claim failed"):
client.claim()
def test_ops_run_to_taskspec(tmp_path: Path) -> None:
import subprocess
repo = tmp_path / "freedom-intelligence"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
(repo / "README.md").write_text("x\n")
subprocess.run(["git", "add", "."], cwd=repo, check=True)
subprocess.run(
["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"],
cwd=repo,
check=True,
)
run = OpsRun(
id="r1",
activity_definition_id="d",
idempotency_key="k",
target_repo="freedom-intelligence",
title="FI",
description="do",
labels=["automated"],
)
cfg = OpsRunConfig(repo_roots=(str(tmp_path),))
spec = ops_run_to_taskspec(run, cfg)
assert spec.target_repo == repo.resolve()
assert spec.hub_task_id == "r1"
def test_ops_run_to_taskspec_missing_repo() -> None:
run = OpsRun(
id="r1",
activity_definition_id="d",
idempotency_key="k",
target_repo=None,
title="t",
description="",
)
with pytest.raises(TaskSpecError):
ops_run_to_taskspec(run, OpsRunConfig())