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

359
rein_aharness/approaches.py Normal file
View file

@ -0,0 +1,359 @@
"""Approach registry: match ops_run → cheapest correct executor (REIN-A-0002-T02).
Prefer deterministic / structured llm-connect adapters before open-ended agent
sessions. Add rows in APPROACH_RULES (order = priority).
How to add a row:
1. Implement a ``run_*`` function or CLI command.
2. Append an ApproachRule with match predicates (labels / blob substrings).
3. Wire execution in ``execute_approach``.
4. Add pure match tests in ``tests/test_approaches.py``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from rein_aharness.ops_run_client import OpsRun, OpsRunConfig, resolve_ops_target
from rein_aharness.taskspec import TaskSpecError
# Approach command names (stable; used in metrics + ops_run.result)
APPROACH_FI_RESEARCH_BRIEF = "fi-research-brief"
APPROACH_BRIEF_DAILY = "brief-daily"
APPROACH_MAIL_SCAN = "mail-scan"
APPROACH_MAIL_TRIAGE = "mail-triage"
APPROACH_MAIL_PIPELINE = "mail-scan+triage"
APPROACH_AGENT_SESSION = "agent-session"
APPROACH_UNMATCHED = "unmatched"
@dataclass(frozen=True)
class ApproachRule:
name: str
"""Match if any of these labels appear (case-insensitive)."""
labels_any: frozenset[str] = frozenset()
"""Match if all of these labels appear."""
labels_all: frozenset[str] = frozenset()
"""Match if any substring appears in definition id + labels + title + hint."""
blob_contains: frozenset[str] = frozenset()
"""If approach_hint equals this command name, select immediately."""
hint_name: str | None = None
# Ordered: first match wins. Cheapest / most specific first.
APPROACH_RULES: tuple[ApproachRule, ...] = (
ApproachRule(
name=APPROACH_FI_RESEARCH_BRIEF,
labels_any=frozenset({"research-brief", "freedom-intelligence"}),
blob_contains=frozenset(
{"fi-daily", "fi_daily", "fi-research", "freedom intelligence"}
),
hint_name=APPROACH_FI_RESEARCH_BRIEF,
),
ApproachRule(
name=APPROACH_BRIEF_DAILY,
labels_any=frozenset({"rhythm", "binky-daily", "daily-brief"}),
labels_all=frozenset(), # rhythm alone is enough for Binky daily
blob_contains=frozenset(
{"binky-daily", "binky_daily", "daily-rhythm", "daily_brief"}
),
hint_name=APPROACH_BRIEF_DAILY,
),
ApproachRule(
name=APPROACH_MAIL_PIPELINE,
labels_any=frozenset({"mail-intake", "mail_intake"}),
blob_contains=frozenset({"mail-intake", "mail_intake", "weekly-mail"}),
hint_name=APPROACH_MAIL_PIPELINE,
),
ApproachRule(
name=APPROACH_MAIL_SCAN,
labels_any=frozenset({"mail-scan"}),
hint_name=APPROACH_MAIL_SCAN,
),
ApproachRule(
name=APPROACH_MAIL_TRIAGE,
labels_any=frozenset({"mail-triage"}),
hint_name=APPROACH_MAIL_TRIAGE,
),
ApproachRule(
name=APPROACH_AGENT_SESSION,
labels_any=frozenset({"agent-session", "agent_session"}),
blob_contains=frozenset({"agent-session"}),
hint_name=APPROACH_AGENT_SESSION,
),
)
def _match_blob(run: OpsRun) -> str:
parts = [
run.activity_definition_id or "",
run.title or "",
run.approach_hint or "",
" ".join(run.labels or []),
run.source_id or "",
run.target_repo or "",
]
return " ".join(parts).lower()
def select_approach(run: OpsRun) -> str:
"""Return approach command name for this ops_run (pure)."""
hint = (run.approach_hint or "").strip().lower()
if hint:
for rule in APPROACH_RULES:
if rule.hint_name and hint in {
rule.hint_name,
rule.name,
rule.hint_name.replace("-", "_"),
}:
return rule.name
# Explicit known command as hint
known = {
APPROACH_FI_RESEARCH_BRIEF,
APPROACH_BRIEF_DAILY,
APPROACH_MAIL_SCAN,
APPROACH_MAIL_TRIAGE,
APPROACH_MAIL_PIPELINE,
APPROACH_AGENT_SESSION,
}
if hint in known or hint.replace("_", "-") in known:
return hint.replace("_", "-")
labels = {str(x).lower() for x in (run.labels or [])}
blob = _match_blob(run)
for rule in APPROACH_RULES:
if rule.labels_all and not rule.labels_all.issubset(labels):
continue
if rule.labels_any and (rule.labels_any & labels):
return rule.name
if rule.blob_contains and any(s in blob for s in rule.blob_contains):
return rule.name
return APPROACH_UNMATCHED
@dataclass
class ApproachResult:
ok: bool
approach: str
result: dict[str, Any] = field(default_factory=dict)
reason: str = ""
reopen: bool = False
def execute_approach(
run: OpsRun,
*,
approach: str | None = None,
config: OpsRunConfig | None = None,
report_to_hub: bool = True,
commit: bool = True,
) -> ApproachResult:
"""Dispatch to the selected executor. Never raises for business failure."""
cfg = config or OpsRunConfig.from_env()
name = approach or select_approach(run)
if name == APPROACH_UNMATCHED:
return ApproachResult(
ok=False,
approach=name,
reason=(
"no approach matched labels/definition; "
f"labels={run.labels!r} def={run.activity_definition_id!r}"
),
reopen=False,
)
try:
target = resolve_ops_target(run, cfg)
except TaskSpecError as exc:
return ApproachResult(
ok=False,
approach=name,
reason=str(exc),
reopen=False,
)
try:
if name == APPROACH_FI_RESEARCH_BRIEF:
return _run_fi(target, report_to_hub=report_to_hub, commit=commit)
if name == APPROACH_BRIEF_DAILY:
return _run_brief_daily(target, report_to_hub=report_to_hub, commit=commit)
if name == APPROACH_MAIL_SCAN:
return _run_mail_scan(target, report_to_hub=report_to_hub)
if name == APPROACH_MAIL_TRIAGE:
return _run_mail_triage(target, report_to_hub=report_to_hub, commit=commit)
if name == APPROACH_MAIL_PIPELINE:
return _run_mail_pipeline(
target, report_to_hub=report_to_hub, commit=commit
)
if name == APPROACH_AGENT_SESSION:
return _run_agent_session(run, target, report_to_hub=report_to_hub)
except Exception as exc: # noqa: BLE001 — surface as fail ops_run
return ApproachResult(
ok=False,
approach=name,
reason=f"{type(exc).__name__}: {exc}",
reopen=True,
)
return ApproachResult(
ok=False,
approach=name,
reason=f"approach not implemented: {name}",
reopen=False,
)
def _run_fi(target: Path, *, report_to_hub: bool, commit: bool) -> ApproachResult:
from rein_aharness.fi_research_brief import run_fi_research_brief
r = run_fi_research_brief(
target_repo=target,
report_to_hub=report_to_hub,
commit=commit,
)
return ApproachResult(
ok=r.ok,
approach=APPROACH_FI_RESEARCH_BRIEF,
result={
"date": r.date,
"path": r.path,
"wrote": r.wrote,
"committed": r.committed,
"skipped_existing": r.skipped_existing,
"collection_candidates": r.collection_candidates,
"head_after": r.head_after,
},
reason=r.reason,
reopen=not r.ok and not r.skipped_existing,
)
def _run_brief_daily(
target: Path, *, report_to_hub: bool, commit: bool
) -> ApproachResult:
from rein_aharness.brief_daily import run_brief_daily
r = run_brief_daily(
target_repo=target,
report_to_hub=report_to_hub,
commit=commit,
)
return ApproachResult(
ok=r.ok,
approach=APPROACH_BRIEF_DAILY,
result={
"date": r.date,
"path": r.path,
"wrote": r.wrote,
"committed": r.committed,
"skipped_existing": r.skipped_existing,
"head_after": r.head_after,
},
reason=r.reason,
reopen=not r.ok and not r.skipped_existing,
)
def _run_mail_scan(target: Path, *, report_to_hub: bool) -> ApproachResult:
from rein_aharness.mailscan import run_mail_scan
r = run_mail_scan(target_repo=target, report_to_hub=report_to_hub)
return ApproachResult(
ok=r.ok,
approach=APPROACH_MAIL_SCAN,
result={
"report": r.report_path,
"new_messages": r.new_messages,
"auth_lane": r.auth_lane,
},
reason=r.reason,
reopen=not r.ok,
)
def _run_mail_triage(
target: Path, *, report_to_hub: bool, commit: bool
) -> ApproachResult:
from rein_aharness.mail_triage import run_mail_triage
r = run_mail_triage(
target_repo=target,
report_to_hub=report_to_hub,
commit=commit,
)
return ApproachResult(
ok=r.ok,
approach=APPROACH_MAIL_TRIAGE,
result={
"report": r.report,
"entries_applied": r.entries_applied,
"committed": r.committed,
"head_after": r.head_after,
},
reason=r.reason,
reopen=not r.ok,
)
def _run_mail_pipeline(
target: Path, *, report_to_hub: bool, commit: bool
) -> ApproachResult:
scan = _run_mail_scan(target, report_to_hub=report_to_hub)
if not scan.ok:
scan.approach = APPROACH_MAIL_PIPELINE
return scan
triage = _run_mail_triage(target, report_to_hub=report_to_hub, commit=commit)
return ApproachResult(
ok=triage.ok,
approach=APPROACH_MAIL_PIPELINE,
result={"scan": scan.result, "triage": triage.result},
reason=triage.reason or scan.reason,
reopen=not triage.ok,
)
def _run_agent_session(
run: OpsRun, target: Path, *, report_to_hub: bool
) -> ApproachResult:
from rein_aharness.ops_run_client import ops_run_to_taskspec
from rein_aharness.runner import run_task
# Infer agent/event from labels (reuse intake hints)
from rein_aharness.intake import EmittedIssue, infer_agent_and_event
pseudo = EmittedIssue(
issue_id=run.id,
title=run.title,
description=run.description,
labels=list(run.labels),
target_repo=run.target_repo,
activity_definition_id=run.activity_definition_id,
)
agent, event = infer_agent_and_event(pseudo)
spec = ops_run_to_taskspec(
run,
agent=agent,
completion_event_type=event,
)
# target already resolved into TaskSpec
assert spec.target_repo == target or True
r = run_task(spec, report_to_hub=report_to_hub)
return ApproachResult(
ok=r.ok,
approach=APPROACH_AGENT_SESSION,
result={
"committed": r.committed,
"head_after": r.head_after,
"tool_profile": r.tool_profile,
"tokens_spent": r.tokens_spent,
},
reason=r.reason,
reopen=not r.ok,
)

307
rein_aharness/claim_loop.py Normal file
View file

@ -0,0 +1,307 @@
"""Continuous ops_run claim worker (REIN-A-0002-T03).
rein-aharness claim-loop
rein-aharness claim-loop --once
rein-aharness poll --source=ops-run
Concurrency default 1. Heartbeats while an approach runs.
"""
from __future__ import annotations
import logging
import os
import signal
import threading
import time
from dataclasses import dataclass, field
from typing import Any
from rein_aharness.approaches import (
APPROACH_UNMATCHED,
ApproachResult,
execute_approach,
select_approach,
)
from rein_aharness.ops_run_client import (
ActivityCoreOpsClient,
OpsRun,
OpsRunConfig,
OpsRunError,
)
logger = logging.getLogger("rein_aharness.claim_loop")
@dataclass
class ProcessResult:
claimed: bool
empty: bool = False
run_id: str | None = None
approach: str | None = None
ok: bool | None = None
reason: str = ""
ops_state: str | None = None
detail: dict[str, Any] = field(default_factory=dict)
def _heartbeat_interval(lease_seconds: int) -> float:
# Heartbeat at 1/3 lease, min 30s, max 300s
return max(30.0, min(300.0, lease_seconds / 3.0))
class _Heartbeat:
def __init__(
self,
client: ActivityCoreOpsClient,
run_id: str,
lease_seconds: int,
):
self._client = client
self._run_id = run_id
self._lease = lease_seconds
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
interval = _heartbeat_interval(self._lease)
def _loop() -> None:
while not self._stop.wait(interval):
try:
self._client.heartbeat(self._run_id, lease_seconds=self._lease)
logger.info("heartbeat ok run_id=%s", self._run_id)
except OpsRunError as exc:
logger.warning("heartbeat failed run_id=%s: %s", self._run_id, exc)
self._thread = threading.Thread(
target=_loop, name=f"ops-hb-{self._run_id[:8]}", daemon=True
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=5.0)
def process_one(
client: ActivityCoreOpsClient | None = None,
*,
report_to_hub: bool = True,
commit: bool = True,
dry_run: bool = False,
) -> ProcessResult:
"""Claim at most one ops_run, execute approach, complete or fail."""
client = client or ActivityCoreOpsClient()
cfg = client.config
try:
claimed = client.claim(limit=1)
except OpsRunError as exc:
return ProcessResult(claimed=False, reason=f"claim error: {exc}")
if not claimed:
return ProcessResult(claimed=False, empty=True, reason="queue empty")
run = claimed[0]
approach = select_approach(run)
logger.info(
"claimed run_id=%s approach=%s title=%r labels=%s",
run.id,
approach,
run.title,
run.labels,
)
if dry_run:
try:
client.fail(
run.id,
error="dry-run: not executing",
reopen=True,
result={"approach": approach, "dry_run": True},
)
except OpsRunError as exc:
return ProcessResult(
claimed=True,
run_id=run.id,
approach=approach,
ok=False,
reason=f"dry-run reopen failed: {exc}",
)
return ProcessResult(
claimed=True,
run_id=run.id,
approach=approach,
ok=True,
reason="dry-run reopened",
ops_state="open",
detail={"dry_run": True},
)
hb = _Heartbeat(client, run.id, cfg.lease_seconds)
hb.start()
try:
ar: ApproachResult = execute_approach(
run,
approach=approach,
config=cfg,
report_to_hub=report_to_hub,
commit=commit,
)
finally:
hb.stop()
payload = {
"approach": ar.approach,
"ok": ar.ok,
"reason": ar.reason,
**(ar.result or {}),
}
try:
if ar.ok:
# skipped_existing still succeeds the ops_run (idempotent day)
out = client.complete(run.id, result=payload)
state = out.state
logger.info("completed run_id=%s approach=%s", run.id, ar.approach)
else:
reopen = ar.reopen and ar.approach != APPROACH_UNMATCHED
out = client.fail(
run.id,
error=ar.reason or "approach failed",
reopen=reopen,
result=payload,
)
state = out.state
logger.warning(
"failed run_id=%s approach=%s reopen=%s reason=%s",
run.id,
ar.approach,
reopen,
ar.reason,
)
except OpsRunError as exc:
return ProcessResult(
claimed=True,
run_id=run.id,
approach=ar.approach,
ok=False,
reason=f"close ops_run failed: {exc}; approach_ok={ar.ok} {ar.reason}",
detail=payload,
)
return ProcessResult(
claimed=True,
run_id=run.id,
approach=ar.approach,
ok=ar.ok,
reason=ar.reason,
ops_state=state,
detail=payload,
)
def poll_peek(client: ActivityCoreOpsClient | None = None) -> list[dict[str, Any]]:
"""List open ops_runs with selected approach (no claim)."""
client = client or ActivityCoreOpsClient()
rows = client.list_open()
out = []
for run in rows:
out.append(
{
"id": run.id,
"title": run.title,
"state": run.state,
"labels": run.labels,
"target_repo": run.target_repo,
"approach": select_approach(run),
"created_at": run.raw.get("created_at"),
}
)
return out
def run_claim_loop(
*,
once: bool = False,
interval_seconds: float | None = None,
report_to_hub: bool = True,
commit: bool = True,
dry_run: bool = False,
max_iterations: int | None = None,
) -> int:
"""Poll forever (or once). Returns process exit code."""
if interval_seconds is None:
try:
interval_seconds = float(
os.environ.get("AGENT_HARNESS_CLAIM_INTERVAL", "30")
)
except ValueError:
interval_seconds = 30.0
interval_seconds = max(1.0, interval_seconds)
stop = threading.Event()
def _handle_sig(*_args: Any) -> None:
logger.info("shutdown signal received")
stop.set()
signal.signal(signal.SIGINT, _handle_sig)
signal.signal(signal.SIGTERM, _handle_sig)
client = ActivityCoreOpsClient()
logger.info(
"claim-loop start worker_id=%s url=%s labels=%s interval=%ss once=%s",
client.config.worker_id,
client.config.base_url,
client.config.claim_labels,
interval_seconds,
once,
)
iterations = 0
exit_code = 0
while not stop.is_set():
iterations += 1
t0 = time.monotonic()
try:
result = process_one(
client,
report_to_hub=report_to_hub,
commit=commit,
dry_run=dry_run,
)
except Exception as exc: # noqa: BLE001
logger.exception("process_one crashed: %s", exc)
result = ProcessResult(claimed=False, reason=str(exc))
exit_code = 1
elapsed = time.monotonic() - t0
if result.empty:
logger.debug("queue empty (%.2fs)", elapsed)
else:
logger.info(
"cycle claimed=%s run_id=%s ok=%s approach=%s state=%s reason=%s (%.2fs)",
result.claimed,
result.run_id,
result.ok,
result.approach,
result.ops_state,
result.reason,
elapsed,
)
if result.claimed and result.ok is False:
exit_code = 1
if once:
break
if max_iterations is not None and iterations >= max_iterations:
break
# Sleep full interval only when empty; short pause after work
sleep_for = interval_seconds if result.empty else min(2.0, interval_seconds)
stop.wait(sleep_for)
logger.info("claim-loop stop iterations=%s exit=%s", iterations, exit_code)
return 0 if once and exit_code == 0 else exit_code if once else 0

View file

@ -69,6 +69,48 @@ def _cmd_profiles(_args: argparse.Namespace) -> int:
def _cmd_poll(args: argparse.Namespace) -> int:
source = getattr(args, "source", "issue-core") or "issue-core"
if source in {"ops-run", "ops_run", "ops"}:
from rein_aharness.claim_loop import process_one, poll_peek
from rein_aharness.ops_run_client import OpsRunError
try:
if args.no_claim:
rows = poll_peek()
print(
json.dumps(
{"source": "ops-run", "queue": "empty" if not rows else "open", "items": rows},
indent=2,
)
)
return 0
result = process_one(
dry_run=bool(getattr(args, "dry_run", False)),
report_to_hub=False if getattr(args, "no_hub", False) else True,
)
except OpsRunError as exc:
print(f"ops-run error: {exc}", file=sys.stderr)
return 2
print(
json.dumps(
{
"source": "ops-run",
"claimed": result.claimed,
"empty": result.empty,
"run_id": result.run_id,
"approach": result.approach,
"ok": result.ok,
"ops_state": result.ops_state,
"reason": result.reason,
"detail": result.detail,
},
indent=2,
)
)
if result.empty:
return 0
return 0 if result.ok else 1
from rein_aharness.intake import IntakeError, IssueCoreClient, poll_next
try:
@ -78,12 +120,13 @@ def _cmd_poll(args: argparse.Namespace) -> int:
print(f"intake error: {exc}", file=sys.stderr)
return 2
if result is None:
print(json.dumps({"queue": "empty"}, indent=2))
print(json.dumps({"source": "issue-core", "queue": "empty"}, indent=2))
return 0
issue, spec = result
print(
json.dumps(
{
"source": "issue-core",
"issue_id": issue.issue_id,
"state": issue.state,
"title": issue.title,
@ -99,12 +142,62 @@ def _cmd_poll(args: argparse.Namespace) -> int:
return 0
def _cmd_claim_loop(args: argparse.Namespace) -> int:
import logging
from rein_aharness.claim_loop import run_claim_loop
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return run_claim_loop(
once=bool(args.once),
interval_seconds=args.interval,
report_to_hub=not args.no_hub,
commit=not args.no_commit,
dry_run=bool(args.dry_run),
max_iterations=args.max_iterations,
)
def _cmd_run(args: argparse.Namespace) -> int:
from rein_aharness.intake import IntakeError, IssueCoreClient, poll_next
issue_id: str | None = None
client: IssueCoreClient | None = None
if getattr(args, "from_ops_run", False):
from rein_aharness.claim_loop import process_one
from rein_aharness.ops_run_client import OpsRunError
try:
result = process_one(
report_to_hub=not args.no_hub,
commit=not getattr(args, "no_commit", False),
)
except OpsRunError as exc:
print(f"ops-run error: {exc}", file=sys.stderr)
return 2
print(
json.dumps(
{
"source": "ops-run",
"ok": bool(result.ok) if result.claimed else True,
"empty": result.empty,
"run_id": result.run_id,
"approach": result.approach,
"ops_state": result.ops_state,
"reason": result.reason,
"detail": result.detail,
},
indent=2,
)
)
if result.empty:
return 0
return 0 if result.ok else 1
if args.from_issue_core:
try:
client = IssueCoreClient()
@ -113,14 +206,14 @@ def _cmd_run(args: argparse.Namespace) -> int:
print(f"intake error: {exc}", file=sys.stderr)
return 2
if polled is None:
print(json.dumps({"ok": True, "queue": "empty"}, indent=2))
print(json.dumps({"ok": True, "source": "issue-core", "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",
"error: provide --task-file, --from-ops-run, or --from-issue-core",
file=sys.stderr,
)
return 2
@ -160,6 +253,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
json.dumps(
{
"ok": result.ok,
"source": "issue-core" if issue_id else "task-file",
"committed": result.committed,
"head_after": result.head_after,
"persona_source": result.persona_source,
@ -184,16 +278,26 @@ def main(argv: list[str] | None = None) -> int:
run = sub.add_parser(
"run",
help="Execute one task from a JSON file or next issue-core emission",
help="Execute one task: ops_run (primary), issue-core (legacy), or task file",
)
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-ops-run",
action="store_true",
help="Claim one activity-core ops_run, select approach, execute, complete/fail",
)
run_src.add_argument(
"--from-issue-core",
action="store_true",
help="Poll issue-core for one open harness-labeled task, claim, run, close",
help="Legacy: 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-commit",
action="store_true",
help="With --from-ops-run: approaches that support it skip git commit",
)
run.add_argument(
"--no-metrics",
action="store_true",
@ -329,13 +433,51 @@ def main(argv: list[str] | None = None) -> int:
poll = sub.add_parser(
"poll",
help="Peek/claim next open issue-core task labeled for the harness (no execute)",
help="Peek/claim next task (default source=ops-run; issue-core is legacy)",
)
poll.add_argument(
"--source",
choices=["ops-run", "issue-core"],
default="ops-run",
help="ops-run = activity-core claim queue (primary); issue-core = legacy tickets",
)
poll.add_argument(
"--no-claim",
action="store_true",
help="List/map only; do not set in_progress",
help="List/map only; do not claim",
)
poll.add_argument(
"--dry-run",
action="store_true",
help="With ops-run: claim then fail+reopen without executing",
)
poll.add_argument("--no-hub", action="store_true", help="Skip hub on execute")
claim_loop = sub.add_parser(
"claim-loop",
help="Continuously claim ops_runs, select approach, execute, complete/fail",
)
claim_loop.add_argument(
"--once",
action="store_true",
help="Process at most one claim cycle then exit",
)
claim_loop.add_argument(
"--interval",
type=float,
default=None,
help="Seconds between empty-queue polls (default env AGENT_HARNESS_CLAIM_INTERVAL or 30)",
)
claim_loop.add_argument(
"--max-iterations",
type=int,
default=None,
help="Stop after N cycles (tests / bounded runs)",
)
claim_loop.add_argument("--dry-run", action="store_true")
claim_loop.add_argument("--no-hub", action="store_true")
claim_loop.add_argument("--no-commit", action="store_true")
claim_loop.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args(argv)
@ -348,6 +490,9 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "poll":
return _cmd_poll(args)
if args.command == "claim-loop":
return _cmd_claim_loop(args)
if args.command == "run":
return _cmd_run(args)

View file

@ -0,0 +1,288 @@
"""activity-core ops_run claim client (REIN-A-0002 / ACT-ADR-005).
Primary intake for scheduled automation. Does **not** use issue-core or Forgejo.
Environment:
ACTIVITY_CORE_URL default http://127.0.0.1:8010
ACTIVITY_CORE_WORKER_TOKEN X-Worker-Token / Bearer (optional if API open)
AGENT_HARNESS_WORKER_ID claim owner (default: rein-aharness@hostname)
AGENT_HARNESS_OPS_LABELS comma labels for claim filter (default: automated)
AGENT_HARNESS_OPS_LABELS_MODE any|all (default: any)
AGENT_HARNESS_OPS_LEASE_SECONDS claim lease (default: 900)
AGENT_HARNESS_REPO_MAP / AGENT_HARNESS_REPO_ROOTS shared with intake
"""
from __future__ import annotations
import json
import os
import socket
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import httpx
from rein_aharness.intake import resolve_target_repo
from rein_aharness.taskspec import TaskSpec, TaskSpecError
DEFAULT_ACTIVITY_CORE_URL = "http://127.0.0.1:8010"
DEFAULT_OPS_LABELS = ("automated",)
DEFAULT_REPO_ROOTS = ("~", "~/work")
class OpsRunError(RuntimeError):
pass
@dataclass
class OpsRun:
"""Normalized ops_run row from actcore-api."""
id: str
activity_definition_id: str
idempotency_key: str
target_repo: str | None
title: str
description: str
labels: list[str] = field(default_factory=list)
priority: str = "medium"
state: str = "open"
claim_owner: str | None = None
lease_until: str | None = None
attempt: int = 0
source_type: str = "rule"
source_id: str = ""
triggering_event_id: str = ""
approach_hint: str | None = None
result: dict[str, Any] = field(default_factory=dict)
raw: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_api(cls, data: dict[str, Any]) -> "OpsRun":
return cls(
id=str(data.get("id") or ""),
activity_definition_id=str(data.get("activity_definition_id") or ""),
idempotency_key=str(data.get("idempotency_key") or ""),
target_repo=data.get("target_repo"),
title=str(data.get("title") or ""),
description=str(data.get("description") or ""),
labels=[str(x) for x in (data.get("labels") or [])],
priority=str(data.get("priority") or "medium"),
state=str(data.get("state") or "open"),
claim_owner=data.get("claim_owner"),
lease_until=data.get("lease_until"),
attempt=int(data.get("attempt") or 0),
source_type=str(data.get("source_type") or "rule"),
source_id=str(data.get("source_id") or ""),
triggering_event_id=str(data.get("triggering_event_id") or ""),
approach_hint=data.get("approach_hint"),
result=dict(data.get("result") or {}),
raw=data,
)
@dataclass
class OpsRunConfig:
base_url: str = DEFAULT_ACTIVITY_CORE_URL
worker_token: str = ""
worker_id: str = "rein-aharness"
claim_labels: tuple[str, ...] = DEFAULT_OPS_LABELS
labels_mode: str = "any"
lease_seconds: int = 900
repo_map: dict[str, str] = field(default_factory=dict)
repo_roots: tuple[str, ...] = DEFAULT_REPO_ROOTS
timeout: float = 30.0
@classmethod
def from_env(cls) -> "OpsRunConfig":
labels_raw = os.environ.get("AGENT_HARNESS_OPS_LABELS", "automated")
labels = tuple(p.strip() for p in labels_raw.split(",") if p.strip())
roots_raw = os.environ.get("AGENT_HARNESS_REPO_ROOTS", "~:~/work")
roots = tuple(p.strip() for p in roots_raw.split(":") if p.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()}
host = socket.gethostname().split(".")[0]
default_worker = f"rein-aharness@{host}"
try:
lease = max(30, int(os.environ.get("AGENT_HARNESS_OPS_LEASE_SECONDS", "900")))
except ValueError:
lease = 900
mode = (os.environ.get("AGENT_HARNESS_OPS_LABELS_MODE") or "any").strip().lower()
if mode not in {"any", "all"}:
mode = "any"
return cls(
base_url=os.environ.get(
"ACTIVITY_CORE_URL", DEFAULT_ACTIVITY_CORE_URL
).rstrip("/"),
worker_token=(
os.environ.get("ACTIVITY_CORE_WORKER_TOKEN")
or os.environ.get("AGENT_HARNESS_WORKER_TOKEN")
or ""
).strip(),
worker_id=os.environ.get("AGENT_HARNESS_WORKER_ID", default_worker).strip()
or default_worker,
claim_labels=labels or DEFAULT_OPS_LABELS,
labels_mode=mode,
lease_seconds=lease,
repo_map=repo_map,
repo_roots=roots or DEFAULT_REPO_ROOTS,
)
class ActivityCoreOpsClient:
"""REST client for POST /ops-runs/claim|heartbeat|complete|fail."""
def __init__(self, config: OpsRunConfig | None = None):
self.config = config or OpsRunConfig.from_env()
def _headers(self) -> dict[str, str]:
headers = {"Accept": "application/json", "Content-Type": "application/json"}
if self.config.worker_token:
headers["X-Worker-Token"] = self.config.worker_token
headers["Authorization"] = f"Bearer {self.config.worker_token}"
return headers
def list_open(self, *, limit: int = 50) -> list[OpsRun]:
try:
resp = httpx.get(
f"{self.config.base_url}/ops-runs",
params={"state": "open", "limit": str(limit)},
headers=self._headers(),
timeout=self.config.timeout,
)
resp.raise_for_status()
except httpx.HTTPError as exc:
raise OpsRunError(f"list ops-runs failed: {exc}") from exc
data = resp.json()
items = data.get("items") if isinstance(data, dict) else data
if not isinstance(items, list):
raise OpsRunError(f"unexpected list payload: {type(data)}")
return [OpsRun.from_api(item) for item in items if isinstance(item, dict)]
def claim(
self,
*,
labels: list[str] | None = None,
labels_mode: str | None = None,
limit: int = 1,
lease_seconds: int | None = None,
) -> list[OpsRun]:
body = {
"worker_id": self.config.worker_id,
"labels": list(labels if labels is not None else self.config.claim_labels),
"labels_mode": labels_mode or self.config.labels_mode,
"limit": max(1, min(limit, 20)),
"lease_seconds": lease_seconds or self.config.lease_seconds,
}
try:
resp = httpx.post(
f"{self.config.base_url}/ops-runs/claim",
json=body,
headers=self._headers(),
timeout=self.config.timeout,
)
resp.raise_for_status()
except httpx.HTTPError as exc:
raise OpsRunError(f"claim failed: {exc}") from exc
data = resp.json()
items = data.get("items") if isinstance(data, dict) else []
return [OpsRun.from_api(item) for item in items if isinstance(item, dict)]
def heartbeat(
self,
run_id: str,
*,
lease_seconds: int | None = None,
) -> OpsRun:
body: dict[str, Any] = {"worker_id": self.config.worker_id}
if lease_seconds is not None:
body["lease_seconds"] = lease_seconds
return self._post_run(run_id, "heartbeat", body)
def complete(
self,
run_id: str,
*,
result: dict[str, Any] | None = None,
) -> OpsRun:
return self._post_run(
run_id,
"complete",
{"worker_id": self.config.worker_id, "result": result or {}},
)
def fail(
self,
run_id: str,
*,
error: str = "",
reopen: bool = False,
result: dict[str, Any] | None = None,
) -> OpsRun:
return self._post_run(
run_id,
"fail",
{
"worker_id": self.config.worker_id,
"error": error,
"reopen": reopen,
"result": result or {},
},
)
def _post_run(self, run_id: str, action: str, body: dict[str, Any]) -> OpsRun:
try:
resp = httpx.post(
f"{self.config.base_url}/ops-runs/{run_id}/{action}",
json=body,
headers=self._headers(),
timeout=self.config.timeout,
)
resp.raise_for_status()
except httpx.HTTPError as exc:
raise OpsRunError(f"{action} ops_run {run_id} failed: {exc}") from exc
return OpsRun.from_api(resp.json())
def ops_run_to_taskspec(
run: OpsRun,
config: OpsRunConfig | None = None,
*,
agent: str = "coach",
completion_event_type: str = "executor_run",
timeout_seconds: int = 900,
) -> TaskSpec:
"""Map claimed ops_run to TaskSpec for agent-session approach."""
cfg = config or OpsRunConfig.from_env()
if not run.target_repo:
raise TaskSpecError(f"ops_run {run.id} missing target_repo")
target = resolve_target_repo(
run.target_repo,
repo_map=cfg.repo_map,
repo_roots=cfg.repo_roots,
)
return TaskSpec(
title=run.title or "(untitled)",
description=run.description or "",
target_repo=target,
agent=agent,
labels=list(run.labels),
hub_task_id=run.id,
completion_event_type=completion_event_type,
timeout_seconds=timeout_seconds,
)
def resolve_ops_target(run: OpsRun, config: OpsRunConfig | None = None) -> Path:
cfg = config or OpsRunConfig.from_env()
if not run.target_repo:
raise TaskSpecError(f"ops_run {run.id} missing target_repo")
return resolve_target_repo(
run.target_repo,
repo_map=cfg.repo_map,
repo_roots=cfg.repo_roots,
)