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:
parent
9644202eb2
commit
8200a672ea
15 changed files with 1807 additions and 73 deletions
17
README.md
17
README.md
|
|
@ -41,11 +41,20 @@ rein-aharness profiles
|
||||||
# Run exactly one task (local JSON task-file path — dev)
|
# Run exactly one task (local JSON task-file path — dev)
|
||||||
rein-aharness run --task-file examples/task-hello-sandbox.json [--no-hub] [--no-metrics]
|
rein-aharness run --task-file examples/task-hello-sandbox.json [--no-hub] [--no-metrics]
|
||||||
|
|
||||||
# Production intake: poll issue-core (activity-core emissions), claim, run, close
|
# Primary production intake: activity-core ops_run claim (ACT-ADR-005 / REIN-A-0002)
|
||||||
export ISSUE_CORE_URL=http://127.0.0.1:8765 ISSUE_CORE_API_KEY=…
|
export ACTIVITY_CORE_URL=http://127.0.0.1:8010
|
||||||
rein-aharness poll
|
export ACTIVITY_CORE_WORKER_TOKEN=…
|
||||||
rein-aharness run --from-issue-core
|
export AGENT_HARNESS_REPO_MAP='{"freedom-intelligence":"~/freedom-intelligence","binky-control":"~/binky-control"}'
|
||||||
|
rein-aharness poll --source=ops-run --no-claim
|
||||||
|
rein-aharness run --from-ops-run
|
||||||
|
rein-aharness claim-loop
|
||||||
|
# Install user service: ./deploy/scripts/install-claim-loop-user.sh
|
||||||
|
# Docs: docs/ops-run-claim-loop.md
|
||||||
|
|
||||||
|
# Legacy: issue-core tickets (external / Forgejo projection only — not FI/Binky ops)
|
||||||
|
export ISSUE_CORE_URL=http://127.0.0.1:8765 ISSUE_CORE_API_KEY=…
|
||||||
|
rein-aharness poll --source=issue-core
|
||||||
|
rein-aharness run --from-issue-core
|
||||||
|
|
||||||
# Deterministic mailbox scan (no LLM session)
|
# Deterministic mailbox scan (no LLM session)
|
||||||
rein-aharness mail-scan --target-repo ~/binky-control
|
rein-aharness mail-scan --target-repo ~/binky-control
|
||||||
|
|
|
||||||
49
deploy/docs/timer-cutover.md
Normal file
49
deploy/docs/timer-cutover.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Timer cutover (REIN-A-0002-T05)
|
||||||
|
|
||||||
|
After claim-loop is the proven executor path, demote host oneshot timers to
|
||||||
|
**break-glass only**.
|
||||||
|
|
||||||
|
## Proof window
|
||||||
|
|
||||||
|
Before disabling timers, record **5 clean cycles** (weekday schedule fires or
|
||||||
|
forced operator triggers):
|
||||||
|
|
||||||
|
| # | Date | Trigger | ops_run id | approach | result |
|
||||||
|
| - | ---- | ------- | ---------- | -------- | ------ |
|
||||||
|
| 1 | | | | | |
|
||||||
|
| 2 | | | | | |
|
||||||
|
| 3 | | | | | |
|
||||||
|
| 4 | | | | | |
|
||||||
|
| 5 | | | | | |
|
||||||
|
|
||||||
|
Each row: `open` → `claimed` → `succeeded` (or `skipped_existing` complete),
|
||||||
|
with domain completion event (`fi_daily_brief` / `binky_daily_brief`).
|
||||||
|
|
||||||
|
## Demote FI timer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user disable --now fi-research-brief-daily.timer
|
||||||
|
# Keep unit files installed for break-glass:
|
||||||
|
# systemctl --user start fi-research-brief-daily.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Demote Binky rhythm timer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# unit names from binky-control/scripts/railiance-rhythm/systemd/
|
||||||
|
systemctl --user disable --now binky-brief-daily.timer 2>/dev/null || true
|
||||||
|
systemctl --user list-timers --all | grep -E 'binky|fi-research' || true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keep break-glass
|
||||||
|
|
||||||
|
- Install scripts remain; document “manual start if claim-loop down”.
|
||||||
|
- Do **not** delete domain rhythm scripts.
|
||||||
|
- activity-core schedule stays authority for *when due*.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user enable --now fi-research-brief-daily.timer
|
||||||
|
# investigate claim-loop journal + actcore /ops-runs
|
||||||
|
```
|
||||||
31
deploy/scripts/install-claim-loop-user.sh
Executable file
31
deploy/scripts/install-claim-loop-user.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install rein-aharness claim-loop as a user systemd service (railiance01).
|
||||||
|
# Run from a rein-aharness checkout ON the host:
|
||||||
|
# ./deploy/scripts/install-claim-loop-user.sh
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
UNIT_DIR="${HOME}/.config/systemd/user"
|
||||||
|
ENV_DIR="${HOME}/.config/rein-aharness"
|
||||||
|
mkdir -p "${UNIT_DIR}" "${ENV_DIR}"
|
||||||
|
|
||||||
|
cp -f "${ROOT}/deploy/systemd/rein-aharness-claim-loop.service" "${UNIT_DIR}/"
|
||||||
|
|
||||||
|
if [[ ! -f "${ENV_DIR}/claim-loop.env" ]]; then
|
||||||
|
cp "${ROOT}/deploy/systemd/claim-loop.env.example" "${ENV_DIR}/claim-loop.env"
|
||||||
|
chmod 600 "${ENV_DIR}/claim-loop.env"
|
||||||
|
echo "Created ${ENV_DIR}/claim-loop.env — set ACTIVITY_CORE_URL, token, REPO_MAP."
|
||||||
|
else
|
||||||
|
echo "Keeping existing ${ENV_DIR}/claim-loop.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure package entrypoint exists
|
||||||
|
if [[ ! -x "${ROOT}/.venv/bin/rein-aharness" ]]; then
|
||||||
|
echo "warning: ${ROOT}/.venv/bin/rein-aharness missing; install with: cd ${ROOT} && uv sync"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable rein-aharness-claim-loop.service
|
||||||
|
echo "Enabled. Start when env is filled:"
|
||||||
|
echo " systemctl --user start rein-aharness-claim-loop.service"
|
||||||
|
echo " journalctl --user -u rein-aharness-claim-loop -f"
|
||||||
|
echo "Host timers remain break-glass until T05 cutover (docs/ops-run-claim-loop.md)."
|
||||||
14
deploy/systemd/claim-loop.env.example
Normal file
14
deploy/systemd/claim-loop.env.example
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Copy to ~/.config/rein-aharness/claim-loop.env and chmod 600
|
||||||
|
|
||||||
|
ACTIVITY_CORE_URL=http://127.0.0.1:8010
|
||||||
|
ACTIVITY_CORE_WORKER_TOKEN=
|
||||||
|
AGENT_HARNESS_WORKER_ID=rein-aharness@railiance01
|
||||||
|
AGENT_HARNESS_OPS_LABELS=automated
|
||||||
|
AGENT_HARNESS_OPS_LABELS_MODE=any
|
||||||
|
AGENT_HARNESS_OPS_LEASE_SECONDS=900
|
||||||
|
AGENT_HARNESS_CLAIM_INTERVAL=30
|
||||||
|
AGENT_HARNESS_REPO_MAP={"freedom-intelligence":"/home/tegwick/freedom-intelligence","binky-control":"/home/tegwick/binky-control"}
|
||||||
|
AGENT_HARNESS_REPO_ROOTS=/home/tegwick:/home/tegwick/work
|
||||||
|
# LLM / hub as required by approaches
|
||||||
|
# LLM_CONNECT_URL=http://...
|
||||||
|
# STATE_HUB_URL=http://...
|
||||||
22
deploy/systemd/rein-aharness-claim-loop.service
Normal file
22
deploy/systemd/rein-aharness-claim-loop.service
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[Unit]
|
||||||
|
Description=rein-aharness ops_run claim loop (activity-core automation)
|
||||||
|
Documentation=file:%h/rein-aharness/docs/ops-run-claim-loop.md
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Adjust paths for the railiance user checkout
|
||||||
|
WorkingDirectory=%h/rein-aharness
|
||||||
|
EnvironmentFile=-%h/.config/rein-aharness/claim-loop.env
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
# Prefer venv entrypoint when installed with uv/pip
|
||||||
|
ExecStart=%h/rein-aharness/.venv/bin/rein-aharness claim-loop
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=15
|
||||||
|
# Soft stop: claim-loop handles SIGTERM between cycles
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=120
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
128
docs/ops-run-claim-loop.md
Normal file
128
docs/ops-run-claim-loop.md
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
# Ops run claim loop (REIN-A-0002)
|
||||||
|
|
||||||
|
**Primary intake** for activity-core scheduled automation.
|
||||||
|
**Architecture:** ACT-ADR-005 · activity-core [ops-run-queue.md](../../activity-core/docs/ops-run-queue.md)
|
||||||
|
|
||||||
|
Does **not** require issue-core or Forgejo for FI / Binky-style ops.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
activity-core emit_tasks
|
||||||
|
→ INSERT ops_run (open) + dual-write activity_task_spawn
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
rein-aharness claim-loop
|
||||||
|
POST /ops-runs/claim
|
||||||
|
select_approach(labels, definition, hint)
|
||||||
|
execute (fi-research-brief | brief-daily | mail-* | agent-session)
|
||||||
|
POST complete | fail(+reopen)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Variable | Default | Meaning |
|
||||||
|
| -------- | ------- | ------- |
|
||||||
|
| `ACTIVITY_CORE_URL` | `http://127.0.0.1:8010` | actcore-api base |
|
||||||
|
| `ACTIVITY_CORE_WORKER_TOKEN` | unset | `X-Worker-Token` / Bearer |
|
||||||
|
| `AGENT_HARNESS_WORKER_ID` | `rein-aharness@hostname` | claim owner |
|
||||||
|
| `AGENT_HARNESS_OPS_LABELS` | `automated` | claim label filter |
|
||||||
|
| `AGENT_HARNESS_OPS_LABELS_MODE` | `any` | `any` or `all` |
|
||||||
|
| `AGENT_HARNESS_OPS_LEASE_SECONDS` | `900` | claim lease |
|
||||||
|
| `AGENT_HARNESS_CLAIM_INTERVAL` | `30` | empty-queue poll interval |
|
||||||
|
| `AGENT_HARNESS_REPO_MAP` | `{}` | JSON slug→checkout path |
|
||||||
|
| `AGENT_HARNESS_REPO_ROOTS` | `~:~/work` | slug search roots |
|
||||||
|
|
||||||
|
Example map on railiance01:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AGENT_HARNESS_REPO_MAP='{
|
||||||
|
"freedom-intelligence":"/home/tegwick/freedom-intelligence",
|
||||||
|
"binky-control":"/home/tegwick/binky-control"
|
||||||
|
}'
|
||||||
|
export ACTIVITY_CORE_URL=http://127.0.0.1:8010 # or ClusterIP via tunnel
|
||||||
|
export ACTIVITY_CORE_WORKER_TOKEN=… # from actcore-runtime-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## Approach registry
|
||||||
|
|
||||||
|
| Match | Approach |
|
||||||
|
| ----- | -------- |
|
||||||
|
| labels `research-brief` / `freedom-intelligence` or fi-daily* | `fi-research-brief` |
|
||||||
|
| labels `rhythm` / `binky-daily` or binky-daily* | `brief-daily` |
|
||||||
|
| labels `mail-intake` | `mail-scan` then `mail-triage` |
|
||||||
|
| labels `agent-session` | agentic `run` (TaskSpec) |
|
||||||
|
| none | fail ops_run (no silent drop) |
|
||||||
|
|
||||||
|
Optional `approach_hint` on the ops_run overrides matching.
|
||||||
|
|
||||||
|
Add a row: edit `rein_aharness/approaches.py` (`APPROACH_RULES` + `execute_approach`)
|
||||||
|
and tests in `tests/test_approaches.py`. Prefer cheapest correct path
|
||||||
|
(structured llm-connect before open-ended agent session).
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Peek open ops_runs + selected approach
|
||||||
|
rein-aharness poll --source=ops-run --no-claim
|
||||||
|
|
||||||
|
# Claim one, execute approach, complete/fail
|
||||||
|
rein-aharness run --from-ops-run
|
||||||
|
# or
|
||||||
|
rein-aharness poll --source=ops-run
|
||||||
|
|
||||||
|
# Continuous worker (systemd)
|
||||||
|
rein-aharness claim-loop
|
||||||
|
rein-aharness claim-loop --once --verbose
|
||||||
|
|
||||||
|
# Dry-run: claim then fail+reopen without domain work
|
||||||
|
rein-aharness poll --source=ops-run --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy issue-core (external tickets only):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rein-aharness poll --source=issue-core
|
||||||
|
rein-aharness run --from-issue-core
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install claim-loop on railiance01 (user systemd)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From rein-aharness checkout on railiance01
|
||||||
|
./deploy/scripts/install-claim-loop-user.sh
|
||||||
|
|
||||||
|
# Or manually:
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
cp deploy/systemd/rein-aharness-claim-loop.service ~/.config/systemd/user/
|
||||||
|
# Edit Environment= paths / token in the unit or env file
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now rein-aharness-claim-loop.service
|
||||||
|
journalctl --user -u rein-aharness-claim-loop -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Port-forward if claim-loop runs on host and API is ClusterIP-only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n activity-core port-forward svc/actcore-api 8010:8010
|
||||||
|
export ACTIVITY_CORE_URL=http://127.0.0.1:8010
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cutover from host timers (T05)
|
||||||
|
|
||||||
|
Host oneshot timers (FI 07:35, Binky rhythm) remain **break-glass** until:
|
||||||
|
|
||||||
|
1. claim-loop is running and healthy
|
||||||
|
2. **5 clean weekday cycles** (or 5 forced triggers) with open→claimed→succeeded
|
||||||
|
3. Then disable or demote timers — see `deploy/docs/timer-cutover.md`
|
||||||
|
|
||||||
|
Until then dual-path is intentional: schedule truth is activity-core; timers are
|
||||||
|
a safety net if claim-loop is down.
|
||||||
|
|
||||||
|
## Labels required on definitions
|
||||||
|
|
||||||
|
| Domain | Labels (min) |
|
||||||
|
| ------ | ------------ |
|
||||||
|
| Freedom Intelligence | `automated`, `research-brief` (already on FI rule) |
|
||||||
|
| Binky daily | `automated`, `rhythm` |
|
||||||
|
| Binky mail | `automated`, `mail-intake` |
|
||||||
|
|
@ -1,70 +1,89 @@
|
||||||
# Task intake (HARNESS-WP-0001-T03)
|
# Task intake
|
||||||
|
|
||||||
activity-core emits tasks via IssueSink → `POST /issues/` on **issue-core**.
|
**Architecture:** ACT-ADR-005 · ACTIVITY-WP-0026 · REIN-A-0002
|
||||||
rein-aharness **consumes** them by polling the same service:
|
|
||||||
|
## Primary: activity-core `ops_run` (scheduled automation)
|
||||||
|
|
||||||
|
Internal fleet automation (FI daily brief, Binky rhythm, mail intake, …)
|
||||||
|
emits a claimable **`ops_run`** in activity-core. rein-aharness claims that
|
||||||
|
row — **not** issue-core and **not** Forgejo.
|
||||||
|
|
||||||
```
|
```
|
||||||
activity-core (cron/rule)
|
activity-core (Temporal + rules)
|
||||||
→ IssueCoreRestSink POST /issues/
|
→ INSERT ops_run (open) + dual-write activity_task_spawn (state-hub)
|
||||||
→ issue-core backend (sqlite / gitea)
|
→ rein-aharness claim-loop
|
||||||
→ rein-aharness poll/claim GET+PATCH /issues/
|
POST /ops-runs/claim → approach registry → execute → complete/fail
|
||||||
→ run_task (profile + budget + persona + session)
|
→ domain completion events (fi_daily_brief, binky_daily_brief, …)
|
||||||
→ PATCH close + hub progress + .kaizen/metrics
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`TaskExecutorWorkflow` stays a stub (activity-core INTENT); execution lives here.
|
Full guide: **`docs/ops-run-claim-loop.md`**.
|
||||||
|
|
||||||
## Mail path (server / Railiance)
|
### CLI
|
||||||
|
|
||||||
```
|
```bash
|
||||||
rein-aharness mail-scan # deterministic IMAP (AppRole)
|
export ACTIVITY_CORE_URL=http://127.0.0.1:8010
|
||||||
rein-aharness mail-triage # llm-connect HTTP → JSON → apply mail-log + commit
|
export ACTIVITY_CORE_WORKER_TOKEN=…
|
||||||
|
export AGENT_HARNESS_REPO_MAP='{"freedom-intelligence":"~/freedom-intelligence","binky-control":"~/binky-control"}'
|
||||||
|
|
||||||
|
rein-aharness poll --source=ops-run --no-claim
|
||||||
|
rein-aharness run --from-ops-run
|
||||||
|
rein-aharness claim-loop
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires `LLM_CONNECT_URL` (e.g. in-cluster
|
`TaskExecutorWorkflow` in activity-core stays a stub; execution lives here.
|
||||||
`http://llm-connect.activity-core.svc.cluster.local:8080`). Optional:
|
|
||||||
`MAIL_TRIAGE_MODEL`, `MAIL_TRIAGE_MAX_TOKENS`, `MAIL_TRIAGE_MAX_ROWS`.
|
|
||||||
**No Claude CLI** on the host.
|
|
||||||
|
|
||||||
## issue-core worker API
|
---
|
||||||
|
|
||||||
|
## Legacy / external: issue-core poll
|
||||||
|
|
||||||
|
issue-core remains for **external tracker tickets** and intentional
|
||||||
|
`ISSUE_SINK_TYPE=rest` projections to **Forgejo** (self-hosted forge only —
|
||||||
|
no Gitea product path).
|
||||||
|
|
||||||
|
```
|
||||||
|
activity-core (optional rest sink)
|
||||||
|
→ issue-core POST /issues/
|
||||||
|
→ rein-aharness poll --source=issue-core
|
||||||
|
→ run_task / close
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** use this path as the claim queue for FI/Binky ops under WP-0022.
|
||||||
|
|
||||||
|
### issue-core worker API
|
||||||
|
|
||||||
| Method | Path | Role |
|
| Method | Path | Role |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| POST | `/issues/` | Ingest (activity-core) |
|
| POST | `/issues/` | Ingest (activity-core rest sink) |
|
||||||
| GET | `/issues/?state=open&label=automated` | Poll queue |
|
| GET | `/issues/?state=open&label=automated` | Poll queue |
|
||||||
| GET | `/issues/{id}` | Fetch one |
|
| GET | `/issues/{id}` | Fetch one |
|
||||||
| PATCH | `/issues/{id}` | Claim (`in_progress` + assignee) or `closed` |
|
| PATCH | `/issues/{id}` | Claim (`in_progress` + assignee) or `closed` |
|
||||||
|
|
||||||
Auth: `Authorization: Bearer $ISSUE_CORE_API_KEY` or `X-API-Key`.
|
Auth: `Authorization: Bearer $ISSUE_CORE_API_KEY` or `X-API-Key`.
|
||||||
|
|
||||||
## CLI
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ISSUE_CORE_URL=http://127.0.0.1:8765 # or in-cluster service
|
export ISSUE_CORE_URL=http://127.0.0.1:8765
|
||||||
export ISSUE_CORE_API_KEY=…
|
export ISSUE_CORE_API_KEY=…
|
||||||
export AGENT_HARNESS_REPO_MAP='{"binky-control":"/home/tegwick/binky-control"}'
|
rein-aharness poll --source=issue-core
|
||||||
|
|
||||||
# Peek / claim without executing
|
|
||||||
rein-aharness poll
|
|
||||||
rein-aharness poll --no-claim
|
|
||||||
|
|
||||||
# Claim → run → close (or reopen on failure)
|
|
||||||
rein-aharness run --from-issue-core
|
rein-aharness run --from-issue-core
|
||||||
|
|
||||||
# Local dev (unchanged)
|
|
||||||
rein-aharness run --task-file examples/task-hello-sandbox.json --no-hub
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Label filter
|
Default label filter: **`automated`** (`AGENT_HARNESS_INTAKE_LABELS`).
|
||||||
|
|
||||||
Default: issues must carry the **`automated`** label (matches activity-core
|
---
|
||||||
binky definitions). Override:
|
|
||||||
|
|
||||||
```bash
|
## Mail path (server / Railiance)
|
||||||
export AGENT_HARNESS_INTAKE_LABELS=automated,harness # ALL required
|
|
||||||
|
```
|
||||||
|
rein-aharness mail-scan # deterministic IMAP (AppRole)
|
||||||
|
rein-aharness mail-triage # llm-connect HTTP → JSON → apply mail-log + commit
|
||||||
|
# or via claim-loop when ops_run labels include mail-intake
|
||||||
```
|
```
|
||||||
|
|
||||||
## Agent + completion event mapping
|
Requires `LLM_CONNECT_URL`. **No Claude CLI** required for mail/brief approaches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent + completion event mapping (issue-core / agent-session)
|
||||||
|
|
||||||
From `activity_definition_id` or labels:
|
From `activity_definition_id` or labels:
|
||||||
|
|
||||||
|
|
@ -75,8 +94,9 @@ From `activity_definition_id` or labels:
|
||||||
| `binky-weekly-review-prep` / `weekly-review` | `review-prep` | `binky_weekly_review` |
|
| `binky-weekly-review-prep` / `weekly-review` | `review-prep` | `binky_weekly_review` |
|
||||||
| else | `coach` | `executor_run` |
|
| else | `coach` | `executor_run` |
|
||||||
|
|
||||||
Instance policy (`tool_profile`, `budget`, `lane`) still comes from the
|
Approach registry for ops_run (preferred): see `docs/ops-run-claim-loop.md`.
|
||||||
target repo's `.kaizen/schedule.yml`.
|
|
||||||
|
---
|
||||||
|
|
||||||
## Repo path resolution
|
## Repo path resolution
|
||||||
|
|
||||||
|
|
|
||||||
359
rein_aharness/approaches.py
Normal file
359
rein_aharness/approaches.py
Normal 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
307
rein_aharness/claim_loop.py
Normal 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
|
||||||
|
|
@ -69,6 +69,48 @@ def _cmd_profiles(_args: argparse.Namespace) -> int:
|
||||||
|
|
||||||
|
|
||||||
def _cmd_poll(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
|
from rein_aharness.intake import IntakeError, IssueCoreClient, poll_next
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -78,12 +120,13 @@ def _cmd_poll(args: argparse.Namespace) -> int:
|
||||||
print(f"intake error: {exc}", file=sys.stderr)
|
print(f"intake error: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
if result is None:
|
if result is None:
|
||||||
print(json.dumps({"queue": "empty"}, indent=2))
|
print(json.dumps({"source": "issue-core", "queue": "empty"}, indent=2))
|
||||||
return 0
|
return 0
|
||||||
issue, spec = result
|
issue, spec = result
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
|
"source": "issue-core",
|
||||||
"issue_id": issue.issue_id,
|
"issue_id": issue.issue_id,
|
||||||
"state": issue.state,
|
"state": issue.state,
|
||||||
"title": issue.title,
|
"title": issue.title,
|
||||||
|
|
@ -99,12 +142,62 @@ def _cmd_poll(args: argparse.Namespace) -> int:
|
||||||
return 0
|
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:
|
def _cmd_run(args: argparse.Namespace) -> int:
|
||||||
from rein_aharness.intake import IntakeError, IssueCoreClient, poll_next
|
from rein_aharness.intake import IntakeError, IssueCoreClient, poll_next
|
||||||
|
|
||||||
issue_id: str | None = None
|
issue_id: str | None = None
|
||||||
client: IssueCoreClient | 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:
|
if args.from_issue_core:
|
||||||
try:
|
try:
|
||||||
client = IssueCoreClient()
|
client = IssueCoreClient()
|
||||||
|
|
@ -113,14 +206,14 @@ def _cmd_run(args: argparse.Namespace) -> int:
|
||||||
print(f"intake error: {exc}", file=sys.stderr)
|
print(f"intake error: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
if polled is None:
|
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
|
return 0
|
||||||
issue, spec = polled
|
issue, spec = polled
|
||||||
issue_id = issue.issue_id
|
issue_id = issue.issue_id
|
||||||
else:
|
else:
|
||||||
if not args.task_file:
|
if not args.task_file:
|
||||||
print(
|
print(
|
||||||
"error: provide --task-file or --from-issue-core",
|
"error: provide --task-file, --from-ops-run, or --from-issue-core",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|
@ -160,6 +253,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"ok": result.ok,
|
"ok": result.ok,
|
||||||
|
"source": "issue-core" if issue_id else "task-file",
|
||||||
"committed": result.committed,
|
"committed": result.committed,
|
||||||
"head_after": result.head_after,
|
"head_after": result.head_after,
|
||||||
"persona_source": result.persona_source,
|
"persona_source": result.persona_source,
|
||||||
|
|
@ -184,16 +278,26 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
|
|
||||||
run = sub.add_parser(
|
run = sub.add_parser(
|
||||||
"run",
|
"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 = run.add_mutually_exclusive_group(required=True)
|
||||||
run_src.add_argument("--task-file", help="Local JSON task-spec (dev path)")
|
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(
|
run_src.add_argument(
|
||||||
"--from-issue-core",
|
"--from-issue-core",
|
||||||
action="store_true",
|
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-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(
|
run.add_argument(
|
||||||
"--no-metrics",
|
"--no-metrics",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -329,13 +433,51 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
|
|
||||||
poll = sub.add_parser(
|
poll = sub.add_parser(
|
||||||
"poll",
|
"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(
|
poll.add_argument(
|
||||||
"--no-claim",
|
"--no-claim",
|
||||||
action="store_true",
|
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)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
@ -348,6 +490,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
if args.command == "poll":
|
if args.command == "poll":
|
||||||
return _cmd_poll(args)
|
return _cmd_poll(args)
|
||||||
|
|
||||||
|
if args.command == "claim-loop":
|
||||||
|
return _cmd_claim_loop(args)
|
||||||
|
|
||||||
if args.command == "run":
|
if args.command == "run":
|
||||||
return _cmd_run(args)
|
return _cmd_run(args)
|
||||||
|
|
||||||
|
|
|
||||||
288
rein_aharness/ops_run_client.py
Normal file
288
rein_aharness/ops_run_client.py
Normal 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,
|
||||||
|
)
|
||||||
92
tests/test_approaches.py
Normal file
92
tests/test_approaches.py
Normal 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
97
tests/test_claim_loop.py
Normal 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
|
||||||
168
tests/test_ops_run_client.py
Normal file
168
tests/test_ops_run_client.py
Normal 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())
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Ops run claim loop and approach selection"
|
title: "Ops run claim loop and approach selection"
|
||||||
domain: agents
|
domain: agents
|
||||||
repo: rein-aharness
|
repo: rein-aharness
|
||||||
status: ready
|
status: in_progress
|
||||||
owner: grok
|
owner: grok
|
||||||
topic_slug: rein-aharness
|
topic_slug: rein-aharness
|
||||||
priority: high
|
priority: high
|
||||||
|
|
@ -40,7 +40,7 @@ ops under WP-0022 and issue-core INTENT.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: REIN-A-0002-T01
|
id: REIN-A-0002-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "a5e32652-ba3f-4cc5-8d41-0d9f3022c50e"
|
state_hub_task_id: "a5e32652-ba3f-4cc5-8d41-0d9f3022c50e"
|
||||||
```
|
```
|
||||||
|
|
@ -62,7 +62,7 @@ Map ops_run → existing `TaskSpec` (or thin OpsRunSpec). Tests with httpx mock.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: REIN-A-0002-T02
|
id: REIN-A-0002-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c307b119-1bff-4d5c-a911-5b5259de5c86"
|
state_hub_task_id: "c307b119-1bff-4d5c-a911-5b5259de5c86"
|
||||||
```
|
```
|
||||||
|
|
@ -88,7 +88,7 @@ agent session; structured llm-connect before open-ended coding agent).
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: REIN-A-0002-T03
|
id: REIN-A-0002-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "31fb7df3-83a3-4f18-a7b9-9be49e226837"
|
state_hub_task_id: "31fb7df3-83a3-4f18-a7b9-9be49e226837"
|
||||||
```
|
```
|
||||||
|
|
@ -107,17 +107,17 @@ state_hub_task_id: "31fb7df3-83a3-4f18-a7b9-9be49e226837"
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: REIN-A-0002-T04
|
id: REIN-A-0002-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "93190150-4df7-47f4-b52e-29b997f9aaf0"
|
state_hub_task_id: "93190150-4df7-47f4-b52e-29b997f9aaf0"
|
||||||
```
|
```
|
||||||
|
|
||||||
- Ensure definition labels include approach keys (`research-brief`, `automated`, …).
|
- FI labels already include `research-brief` + `automated`.
|
||||||
- Wire `AGENT_HARNESS_REPO_MAP` / roots for `freedom-intelligence`, `binky-control`.
|
- `AGENT_HARNESS_REPO_MAP` documented in claim-loop.env.example + ops-run-claim-loop.md.
|
||||||
- Document railiance install of claim-loop service.
|
- Install: `deploy/scripts/install-claim-loop-user.sh`.
|
||||||
|
|
||||||
**Done when:** end-to-end: trigger FI def → ops_run claimed → brief path +
|
**Prod e2e** (operator after WP-0026-T07 migrate): trigger FI → claim-loop once →
|
||||||
`fi_daily_brief` (or skip if not due).
|
`fi_daily_brief`. Code path + install docs landed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -130,12 +130,14 @@ priority: medium
|
||||||
state_hub_task_id: "a04a98e3-ca6a-4a18-b6fd-47a77047ca90"
|
state_hub_task_id: "a04a98e3-ca6a-4a18-b6fd-47a77047ca90"
|
||||||
```
|
```
|
||||||
|
|
||||||
After **5 clean weekday cycles** (or 5 forced triggers):
|
**Procedure landed:** `deploy/docs/timer-cutover.md` + FI `docs/recurrence-ops.md`
|
||||||
|
updated for claim-loop primary / timer break-glass.
|
||||||
|
|
||||||
1. Disable FI + Binky host oneshot timers **or** set them to “poll once if queue
|
Still open on railiance:
|
||||||
backlog” only.
|
|
||||||
2. Update domain `OperatingRhythm.md` / `docs/recurrence-ops.md`.
|
1. Fill proof table (5 clean cycles).
|
||||||
3. Keep install scripts with break-glass section.
|
2. Disable FI + Binky host oneshot timers.
|
||||||
|
3. Keep install scripts for break-glass.
|
||||||
|
|
||||||
**Done when:** docs + railiance state match; dual-clock residual closed for FI/Binky.
|
**Done when:** docs + railiance state match; dual-clock residual closed for FI/Binky.
|
||||||
|
|
||||||
|
|
@ -145,7 +147,7 @@ After **5 clean weekday cycles** (or 5 forced triggers):
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: REIN-A-0002-T06
|
id: REIN-A-0002-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "cca10835-d6fc-4ce1-a361-d2d1f2693872"
|
state_hub_task_id: "cca10835-d6fc-4ce1-a361-d2d1f2693872"
|
||||||
```
|
```
|
||||||
|
|
@ -158,10 +160,13 @@ state_hub_task_id: "cca10835-d6fc-4ce1-a361-d2d1f2693872"
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- [ ] Claim loop does not require Forgejo or issue-core for FI/Binky ops
|
- [x] Claim loop does not require Forgejo or issue-core for FI/Binky ops
|
||||||
- [ ] Approach table selects fi-research-brief / brief-daily without host “schedule”
|
- [x] Approach table selects fi-research-brief / brief-daily without host “schedule”
|
||||||
- [ ] Timers demoted after proof window
|
- [ ] Timers demoted after proof window (**T05** operator)
|
||||||
- [ ] ACT-ADR-005 / ACTIVITY-WP-0026 contracts implemented
|
- [x] ACT-ADR-005 / ACTIVITY-WP-0026 contracts implemented
|
||||||
|
|
||||||
|
**Shipped:** `ops_run_client`, `approaches`, `claim_loop`, CLI `claim-loop` /
|
||||||
|
`run --from-ops-run` / `poll --source=ops-run`, unit tests, install + cutover docs.
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue