mrcharles 8614827d44 loop: shared bounded-turn core accepts history + knobs (ServeTurn)
Turn machinery refactored so the upcoming serve front door reuses it
instead of copying it: the round loop is now runTurn over arbitrary
message history with an optional tool palette and forwarded max_tokens/
temperature. The serve path (ServeTurn) is stateless chat over client
history with tools hard-off: a tool call the model produces anyway is
refused as a tool result, never executed. Also exposes the model router
(so serve can build its catalog) and a sorted class list; llm requests
can now carry temperature.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-29 01:07:47 -05:00
2026-08-28 18:52:58 -05:00

MOPAC harness

The MOPAC harness is a headless agent conductor written in Go: it pulls a task from Redmine (or a demo issue), routes it to the right model through LiteLLM, runs ONE bounded turn with an allow-listed bash tool, and writes the result as a REPORT file — with harness loop it also drives ITSELF: Redmine is the SoR, the loop is the worker, no bash middle layer. There is no TUI — humans and other agents interact with a stack only through Redmine (SoR), Discourse (docs) and Gitea (code), never by attaching to the loop.

Status: 2026-08-28 — skeleton + event receiver + self-host loop live: harness loop polls the intake, runs one bounded turn per new/updated issue, notes the REPORT back on the issue and transitions status (fake-Redmine e2e test-asserted); MVP demo path live since earlier the same day (single GLM turn through LiteLLM to REPORT); harness events webhook receiver smoke-proven on LAN port 4100.

Quickstart

All dev work happens inside a Docker builder (host stays toolchain-free); docker pull of the builder is pre-authorized. Commands below were verified on 2026-08-28 from a fresh clone.

Build and test

dev.sh routes every compile/vet/test path through the digest-pinned builder container (the host stays toolchain-free):

./dev.sh check   # = go build + go vet + go test, all inside the builder

The equivalent raw command:

docker run --rm -v "$PWD:/h" -w /h \
  -u "$(id -u):$(id -g)" -e HOME=/tmp \
  golang@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514 \
  sh -c 'go build -o bin/harness ./cmd/harness && go vet ./... && go test ./...'

(that digest = golang:1.26-bookworm; alpine lacks bash, which the exec tool's tests need.)

Expected output (tail):

ok  	ukrrs.com/mopac/harness/internal/config
ok  	ukrrs.com/mopac/harness/internal/intake
ok  	ukrrs.com/mopac/harness/internal/llm
ok  	ukrrs.com/mopac/harness/internal/loop
ok  	ukrrs.com/mopac/harness/internal/models
ok  	ukrrs.com/mopac/harness/internal/tools
ok  	ukrrs.com/mopac/harness/internal/writeback

(The module path is ukrrs.com/mopac/harness; the repo lives at git.knownelement.com/ukrrs/MOPAC.)

Configure

cp harness.toml.example harness.toml

harness.toml is gitignored. It holds no secrets — only key refs (env:NAME, file:PATH, literal:VALUE; bw: is reserved for the bitwarden wrapper, build phase 3) that are resolved at runtime and redacted from every error path.

Dry-run (no LLM call, no REPORT)

./bin/harness once --dry-run --demo

Expected output:

harness: intake: demo issue from harness.toml [demo]
harness: task demo-1 (demo): "MVP demo: GLM self-description" class=primary -> mopac-primary -> glm-5.3
PLAN (dry-run)
  vertical: demo
  task: [demo-1] MVP demo: GLM self-description (source demo)
  routing: class "primary" -> tier mopac-primary -> model glm-5.3
  tools: bash (allow=18 deny=7 default=deny timeout=60s)
  bound: max 8 rounds
harness: dry-run complete, no LLM call made

Live demo turn (the MVP bar)

HARNESS_LITELLM_KEY=<vertical virtual key> ./bin/harness once --demo

The model's reply lands verbatim in reports/REPORT-latest.md with model / tier / class / tokens / rounds telemetry. Without the key the run fails fast and clean (verified):

harness: litellm key: environment variable HARNESS_LITELLM_KEY is not set

exit code 1 (config error). The with-key path was proven live on 2026-08-28: the [demo] prompt "tell me about yourself" routed to glm-5.3 via tier mopac-primary and the GLM self-description landed as the REPORT.

Self-host loop (harness loop)

The loop makes the harness its own worker: Redmine is the SoR, the loop is the conductor. It polls the /issues.json intake every [loop] poll_interval_secs (default 120; -interval overrides, --once = single scan for cron-style setups) and, for every issue not yet processed at its current updated_on:

  1. runs ONE bounded turn (the same conductor once uses; sequential, v0 — one turn at a time, no concurrency knob yet);
  2. writes the REPORT file as always;
  3. POSTs the REPORT body back onto the issue as a Redmine journal note (PUT /issues/{id}.json);
  4. transitions status per [redmine.status_map] (e.g. "In Progress" = "Done"; names resolved via /issue_statuses.json, cached);
  5. optionally commits the REPORT to Gitea ([gitea] commit_reports, off by default);
  6. refreshes its dedup marker to the issue's post-writeback updated_on so its own note never re-triggers it.

State is an append-only state/loop/loop.jsonl ([loop] state_dir): one JSON line per action (dispatch / report / note / status / commit / refresh / error), rebuilt into the dedup index at startup — restarts keep exactly-once-reaction semantics. A failed turn is recorded and NOT retried (update the issue to re-release it), so a down proxy cannot hot-loop the poll. Every action logs one line to stdout and one to the JSONL.

./bin/harness loop                # daemon: poll, dispatch, write back; SIGINT stops
./bin/harness loop --dry-run      # scan + print what would dispatch; no turns, no state
./bin/harness loop --once         # single scan (cron-able)

Secrets may come from the keyproxy hop: key_ref = "mpk:mpk-redmine" in harness.toml plus a [keyproxy] section resolves through mopac-keyproxy's POST /v1/resolve (short in-memory cache). env: / file: / literal: refs keep working untouched, so the loop runs with or without keyproxy up.

Webhook receiver (harness events)

Redmine / Discourse / Gitea webhooks punch the harness through the event receiver. Configure [events] in harness.toml (secrets as env refs — see harness.toml.example), then:

HARNESS_REDMINE_WEBHOOK_SECRET=... HARNESS_DISCOURSE_WEBHOOK_SECRET=... \
HARNESS_GITEA_WEBHOOK_SECRET=... ./dev.sh events

publishes port 4100 on the host LAN (override with -listen). Routes:

Route Verification Maps to
POST /hooks/redmine shared-secret header (default X-Redmine-Webhook-Secret) dispatch_turn on issue update/note
POST /hooks/discourse shared-secret header (default X-Discourse-Webhook-Secret) respond_turn on post reply
POST /hooks/gitea HMAC-SHA256 X-Gitea-Signature over the raw body pipeline_step on PR approved/merged
GET /healthz none liveness

Unsigned or unverified deliveries get 401; verified ones are normalized to one internal event record, appended to state/events/events.jsonl (0600, deduped by provider event id: X-Gitea-Delivery / X-Discourse-Event-Id, payload-digest fallback), and handed to the conductor (turn dispatch is a stub until phase 3 wiring). End-to-end smoke, including the 401/200 paths and the JSONL record, driven with python urllib (curl is banned on host):

./dev.sh smoke

Help

./bin/harness help

Prints the full usage block reproduced under CLI reference.

Architecture

flowchart LR
    P(["harness loop<br/>poll every poll_interval_secs"]) --> I["INTAKE<br/>/issues.json scope query"]
    I -->|"id + updated_on not in loop.jsonl"| T["BOUNDED TURN<br/>per issue, sequential (v0)"]
    T --> R["REPORT<br/>reports/ file (atomic)"]
    R --> N["REDMINE WRITEBACK<br/>journal note (PUT /issues/{id}.json)<br/>+ status per [redmine.status_map]"]
    G["GITEA COMMIT<br/>optional, [gitea] commit_reports"] -.-> N
    R -.-> G
    N --> S[("state/loop/loop.jsonl<br/>append-only, dedup index<br/>refreshed to post-writeback updated_on")]
    S -->|"next scan skips processed"| I

The loop replaces the crossfeed bash stack (semaphore slot files, queue scripts, screen doorbells): concurrency is simply "one turn at a time" for v0, wake discipline is the poll interval, and the only cross-process state is the append-only JSONL.

One conductor iteration per process (harness once) still works exactly as before for manual/chained runs:

flowchart TD
    S(["harness once"]) --> I["INTAKE<br/>Redmine /issues.json scope query<br/>or the [demo] issue"]
    I -->|"task + class"| RT["ROUTING<br/>class -&gt; tier -&gt; concrete model<br/>[models] + [models.classes]"]
    RT --> T["BOUNDED TURN<br/>OpenAI-compatible chat via LiteLLM<br/>gated bash tool, max_rounds cap"]
    T --> W["REPORT<br/>reports/REPORT-&lt;vertical&gt;-&lt;task&gt;-&lt;ts&gt;.md<br/>+ REPORT-latest.md, atomic write"]
    W --> E(["exit 0"])
    E -.->|"re-invoke to chain the next turn"| S

One conductor iteration per process: INTAKE resolves the released scope (a Redmine query, or the [demo] issue with --demo) into a task with a class; ROUTING maps the class through the config-only tier table to a concrete proxy model; the bounded TURN drives an OpenAI-compatible chat loop through LiteLLM (retry/backoff on 429/5xx/transport, usage accounting) with a tool-calling loop capped at max_rounds, where the only tool today is an allow-listed bash exec whose gate denials feed back to the model as tool results instead of failing the turn; WRITEBACK persists the final assistant reply as a REPORT file plus REPORT-latest.md (atomic tmp+rename). On a mid-turn LLM failure after content exists, a partial REPORT is still written with the error as the stop reason. Chaining is by re-invocation; the loop is the daemon form of the same iteration, run per new/updated issue with the SoR writebacks on top.

The event path is a second door into the same loop:

flowchart LR
    RM["Redmine webhook"] --> V["VERIFY<br/>shared-secret / HMAC headers"]
    DC["Discourse webhook"] --> V
    GT["Gitea webhook"] --> V
    V -->|401 unverified| X(["rejected"])
    V --> N["NORMALIZE<br/>one Event record: source, kind, actor,<br/>subject id, digest, received-at"]
    N --> S["STORE<br/>state/events/events.jsonl, append-only,<br/>dedup by provider event id"]
    S -->|"dispatch_turn / respond_turn / pipeline_step"| C(["conductor (dispatch stub)"])

Verification rejects unsigned and unverified deliveries with 401 (no detail beyond "unverified"); secret material and header values never reach logs or the event log — only normalized fields and a payload digest do.

CLI reference

Subcommands (from harness help):

Subcommand Purpose
harness help print usage (also -h, --help)
harness once run ONE conductor iteration, then exit
harness loop run the self-host daemon until SIGINT (poll -> turn -> note/status writeback)
harness events run the webhook receiver until SIGINT/SIGTERM

Flags for once:

Flag Meaning
-config PATH config file (default $HARNESS_CONFIG, then ./harness.toml)
-dry-run intake + plan only; no LLM call, no REPORT
-demo run the [demo] issue instead of Redmine intake
-task-id ID run only the task/issue with this id

Flags for loop:

Flag Meaning
-config PATH config file (default $HARNESS_CONFIG, then ./harness.toml)
-interval DUR poll interval (overrides [loop] poll_interval_secs)
-once single scan then exit (cron-able)
-dry-run scan + print what would dispatch; no turns, no state writes

Flags for events:

Flag Meaning
-config PATH config file (default $HARNESS_CONFIG, then ./harness.toml)
-listen ADDR bind address (overrides [events] listen)

Exit codes:

Code Meaning
0 ok (including "no tasks in scope"; loop: clean SIGINT stop)
1 usage / config / routing / writeback error
2 intake error
4 llm / turn error

Configuration

Every harness.toml section (see harness.toml.example, load-tested so it cannot rot):

Section Keys Meaning
top level vertical vertical/stack identity for this harness instance
work_root where the bash tool executes (default .)
report_dir where REPORT files land (default reports)
[loop] max_rounds max LLM round trips per turn, tool calls included (default 8)
poll_interval_secs harness loop scan interval (default 120)
state_dir loop state dir (default state/loop); loop.jsonl created 0600, one JSON line per action
[redmine] url, key_ref SoR issues endpoint + auth key ref
scope_query / scope_query_id released-scope filter: raw /issues.json params, or a saved query id (scope_query wins when both are set)
class_field, default_class custom field carrying the task class; fallback for issues without it (default Class / primary)
limit max issues fetched per intake (default 50)
[redmine.status_map] "In Progress" = "Done" style entries loop: after a REPORT is noted, an issue whose current status matches a key moves to the value (names resolved via /issue_statuses.json); empty map = no transitions. Quoted keys allow spaces
[litellm] base_url, key_ref OpenAI-compatible proxy endpoint + key ref
timeout_secs, max_retries per-request timeout (default 120) and retry count (default 2)
[keyproxy] url, token_ref optional mopac-keyproxy resolve hop for mpk: refs (bearer token is itself a LOCAL ref); url + token_ref are all-or-nothing
cache_ttl_secs in-memory mpk: resolution cache TTL (default 60)
[gitea] url, key_ref, owner, repo optional REPORT-commit step; all required when commit_reports = true
branch, commit_reports target branch (empty = repo default); the whole step is off by default
[models] tier keys, default_tier tier alias → concrete proxy model (see below)
[models.classes] class keys task class → tier alias (see below)
[tools.bash] enabled, timeout_secs, max_output_bytes bash gate on/off, per-command timeout (default 60s), output truncation (default 100000)
default verdict for commands matching no rule: allow or deny (org preset: deny)
allow, deny rule lists; deny beats allow; cmd * word-boundary, pfx* raw prefix, pfx/** path prefix, * universal; compound commands checked segment by segment; command substitution and subshells always denied
[demo] id, subject, prompt, class the issue --demo runs instead of Redmine intake
[events] listen receiver bind address (default :4100; publish via docker -p)
state_dir event log dir (default state/events); events.jsonl created 0600
[events.redmine] secret_ref, secret_header shared-secret ref + header name (default X-Redmine-Webhook-Secret)
[events.discourse] secret_ref, secret_header shared-secret ref + header name (default X-Discourse-Webhook-Secret)
[events.gitea] secret_ref HMAC secret ref; verification is always X-Gitea-Signature (hex HMAC-SHA256 of the raw body)

Key refs accepted anywhere a *_ref appears: env:NAME, file:PATH, literal:VALUE (last resort), mpk:PLACEHOLDER (resolved through the optional [keyproxy] hop — see mopac-keyproxy; mpk:redmine and mpk:mpk-redmine are equivalent), and bw:REF (reserved; errors until the bitwarden wrapper lands in build phase 3).

Model routing

Static, config-only, no heuristics: a task's class maps to a tier alias, the tier alias maps to the concrete model actually sent to the proxy. Unknown class = hard error naming the config section.

Tiers ([models]):

Tier Concrete model Role
mopac-study glm-4.7-flash flash tier
mopac-code glm-5.2 flagship
mopac-review glm-5-turbo mid
mopac-primary glm-5.3 default / flagship+
mopac-vision glm-4.6v vision when needed

Classes ([models.classes]):

Task class Tier Concrete model
study mopac-study glm-4.7-flash
read mopac-study glm-4.7-flash
code mopac-code glm-5.2
architecture mopac-code glm-5.2
review mopac-review glm-5-turbo
summarize mopac-review glm-5-turbo
writeback mopac-review glm-5-turbo
vision mopac-vision glm-4.6v
primary mopac-primary glm-5.3
(no class) default_tier = mopac-primary glm-5.3

Status

Sourced from REPORT.md — keep both in sync.

Area State Detail
Config Works TOML-subset parser, defaults + validation, secret refs only, redacted errors
Model routing v0 Works tier + class maps; concrete model resolved before the request leaves
Conductor single-shot Works harness once chainable; --dry-run makes zero LLM calls (test-asserted)
Redmine intake Works /issues.json scope query, class custom field, --demo fallback
Bounded turn Works LiteLLM chat, retry/backoff, tool loop capped at max_rounds, partial REPORT on mid-turn failure
Exec tool Works allow-listed bash, segment-wise compound checks, timeout + truncation
REPORT writeback Works timestamped file + REPORT-latest.md, atomic, full telemetry
Event receiver Works harness events: verify (HMAC/shared-secret) → normalize → append-only JSONL with provider-id dedup → action mapping; smoke-proven on LAN port 4100
Self-host loop Works harness loop: poll intake → sequential bounded turns → REPORT note writeback → status map → dedup by id + updated_on (append-only loop.jsonl); --once, --dry-run, -interval; fake-Redmine e2e test-asserted
Redmine note writeback Works loop path POSTs the REPORT body as a journal note (PUT /issues/{id}.json); once stays file-only
Status transitions Works [redmine.status_map] names → ids via /issue_statuses.json; refresh advances the dedup marker past its own writes
mpk: key refs Works [keyproxy] hop (POST /v1/resolve, bearer, cached); local refs unaffected
Gitea REPORT commit Works (off) [gitea] commit_reports: contents-API create-or-update right after the REPORT lands
Event → turn dispatch Stubbed conductor DispatchEvent prints what it would do; wiring is phase 3
Tests Works table-driven, stdlib only; build/vet/test clean on go1.26
Budget/semaphore gate Stubbed tokens in REPORT, no cost/spend enforcement yet
bw: key refs Stubbed error until the bitwarden wrapper (phase 3)
Loop concurrency Stubbed v0 = one turn at a time; in-process concurrency knob later
Streaming + turn resume Stubbed retry is request-level today
Session persistence/repair Stubbed not started
Write confinement Stubbed declared roots + symlink checks land with the permission layer
Local inbox intake Stubbed not started
Next (phase 3) Next bitwarden wrapper, full permission layer, event → turn wiring, budget gate via LiteLLM spend APIs, streaming with resume, loop concurrency knob

License

AGPLv3 — see LICENSE.

S
Description
MOPAC custom Go harness (headless, multi-vertical, LiteLLM) - AGPLv3
Readme AGPL-3.0
363 KiB
Languages
Go 96.1%
Shell 2.1%
Python 1.8%