From 2901bb8cae0415eac02229e3840f54d48d7615e3 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Fri, 28 Aug 2026 22:21:49 -0500 Subject: [PATCH] docs + dev.sh: loop in README/REPORT, ./dev.sh loop runner, scan-failure exit codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README gains the self-host loop section (flow diagram, config reference rows for [loop]/[redmine.status_map]/[keyproxy]/[gitea], CLI flags, status table); REPORT.md records phase 3a including the die list for the bash stack it replaces. dev.sh grows a `loop` runner (repo bind-mounted, key env passthrough). `harness loop --once` now exits 2 when the intake scan itself fails so cron setups alert loudly; daemon mode still logs and retries. ๐Ÿ’˜ Generated with Crush Assisted-by: Crush:glm-5.2 --- .gitignore | 1 + README.md | 115 +++++++++++++++++++++++++++++++++++----- REPORT.md | 78 ++++++++++++++++++++++++--- cmd/harness/main.go | 7 ++- dev.sh | 17 +++++- internal/loop/daemon.go | 12 +++-- 6 files changed, 203 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 869fb45..da10912 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ reports/ bin/ *.test .smoke/ +state/ diff --git a/README.md b/README.md index 0fac6c7..e125053 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,17 @@ 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. There is no daemon and no TUI โ€” callers chain turns by -re-invoking `harness once`. It is the execution core for the org's vertical -stacks: humans and other agents interact with a stack only through Redmine -(SoR), Discourse (docs) and Gitea (code), never by attaching to the loop. +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; MVP demo path live (single -GLM turn through LiteLLM to REPORT, proven live 2026-08-28); `harness -events` webhook receiver smoke-proven on LAN port 4100 the same day. +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 @@ -103,6 +106,45 @@ 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. + +```sh +./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](https://git.knownelement.com/ukrrs/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 @@ -144,6 +186,26 @@ Prints the full usage block reproduced under [CLI reference](#cli-reference). ## Architecture +```mermaid +flowchart LR + P(["harness loop
poll every poll_interval_secs"]) --> I["INTAKE
/issues.json scope query"] + I -->|"id + updated_on not in loop.jsonl"| T["BOUNDED TURN
per issue, sequential (v0)"] + T --> R["REPORT
reports/ file (atomic)"] + R --> N["REDMINE WRITEBACK
journal note (PUT /issues/{id}.json)
+ status per [redmine.status_map]"] + G["GITEA COMMIT
optional, [gitea] commit_reports"] -.-> N + R -.-> G + N --> S[("state/loop/loop.jsonl
append-only, dedup index
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: + ```mermaid flowchart TD S(["harness once"]) --> I["INTAKE
Redmine /issues.json scope query
or the [demo] issue"] @@ -164,7 +226,9 @@ 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 โ€” there is no daemon. +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: @@ -191,6 +255,7 @@ Subcommands (from `harness help`): |---|---| | `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`: @@ -202,6 +267,15 @@ Flags for `once`: | `-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 | @@ -213,7 +287,7 @@ Exit codes: | Code | Meaning | |---|---| -| 0 | ok (including "no tasks in scope") | +| 0 | ok (including "no tasks in scope"; loop: clean SIGINT stop) | | 1 | usage / config / routing / writeback error | | 2 | intake error | | 4 | llm / turn error | @@ -229,12 +303,19 @@ cannot rot): | | `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) | @@ -248,8 +329,10 @@ cannot rot): | `[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), and `bw:REF` (reserved; errors until the -bitwarden wrapper lands in build phase 3). +`literal:VALUE` (last resort), `mpk:PLACEHOLDER` (resolved through the +optional `[keyproxy]` hop โ€” see [mopac-keyproxy](https://git.knownelement.com/ukrrs/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 @@ -296,17 +379,21 @@ Sourced from [REPORT.md](REPORT.md) โ€” keep both in sync. | 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 | -| Redmine note writeback | Stubbed | REPORT is file-only today | | 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 | -| Task ordering | Stubbed | first-in-scope; chain with `--task-id` until status transitions land | | Local inbox intake | Stubbed | not started | -| Next (phase 3) | Next | bitwarden wrapper, full permission layer, Redmine note writeback + status transitions, budget gate via LiteLLM spend APIs, streaming with resume | +| Next (phase 3) | Next | bitwarden wrapper, full permission layer, event โ†’ turn wiring, budget gate via LiteLLM spend APIs, streaming with resume, loop concurrency knob | ## Docs and links diff --git a/REPORT.md b/REPORT.md index d5260c7..84ff170 100644 --- a/REPORT.md +++ b/REPORT.md @@ -63,7 +63,6 @@ without a key, so the live run needs exactly one thing: - Event โ†’ turn dispatch: `harness events` stores + maps events and hands them to `Conductor.DispatchEvent`, which is a printing stub (phase 3). -- Redmine issue-note writeback (SoR note after the REPORT) โ€” file only. - Budget/semaphore GATE (LiteLLM spend APIs, class-aware slots) and cost in REPORT (tokens only today). - `bw:` key refs error until the bitwarden wrapper (phase 3). @@ -71,8 +70,8 @@ without a key, so the live run needs exactly one thing: - Session persistence + read-time repair (crush notes ยง2) โ€” not started. - Write confinement to declared roots + symlink-aware canonicalization; redirects currently match by segment text (phase 3 permission layer). -- Task selection is first-in-scope; no P1-first ordering or status - transitions, so chained `once` re-picks the same issue (use --task-id). +- `once` task selection is first-in-scope (`--task-id` to chain); the + loop's dedup + status map supersede this for self-hosted runs. - Local inbox intake (DESIGN core-loop step 1, second half) โ€” not started. ## Build phase 2b โ€” events receiver (`mopac events`) โ€” 2026-08-28 @@ -127,13 +126,78 @@ Stubbed: action; real wiring lands in phase 3 per plan). - Cross-host/event forwarding (T1 replication later; JSONL is local-only). +## Build phase 3a โ€” self-host core (`harness loop`) โ€” 2026-08-28 ~22:00 + +Charles (21:30): "get to be self hosting asap โ€” smallest core that can +bootstrap itself (redmine/gitea interaction fine, discourse later)". The +harness now drives ITSELF: Redmine is the SoR, the loop is the worker, no +bash middle layer. + +Works: + +- **`harness loop` CLI**: the self-host daemon. Polls the `/issues.json` + intake every `[loop] poll_interval_secs` (default 120; `-interval` + override; `--once` = single scan for cron-style setups; `--dry-run` = + scan + print, zero LLM calls, zero state writes โ€” test-asserted). +- **Dispatch discipline**: one bounded turn per issue not yet processed at + its current `updated_on`, strictly sequential (v0 โ€” the concurrency + slot-file cap of the old stack is replaced by "one at a time"; a knob + can come later). The dedup marker is written BEFORE the turn, so a + failed turn is recorded and never retried (update the issue to + re-release it) โ€” a down proxy cannot hot-loop the poll. +- **State**: append-only `state/loop/loop.jsonl` (0600, dir 0700), one + JSON line per action (dispatch / report / note / status / commit / + refresh / error), index rebuilt at startup (torn tail skipped). Restart + keeps exactly-once-reaction semantics โ€” test-asserted across a new + Conductor instance. +- **Redmine note writeback** (was the phase-2 stub): after the turn, the + REPORT body is POSTed onto the issue as a journal note + (PUT `/issues/{id}.json`), so the SoR carries the result, not just the + filesystem. +- **Status transitions**: `[redmine.status_map]` (quoted TOML keys, e.g. + `"In Progress" = "Done"`); names resolve to ids via + `/issue_statuses.json` (fetched once, cached, case-insensitive); + unmapped statuses are left alone. +- **Self-retrigger prevention**: the loop's own note/status writes bump + `updated_on`; after writeback the loop re-fetches the issue and + advances its dedup marker to the post-writeback `updated_on` + (`refresh` event). If that refresh call fails, the JSONL records the + error and one redundant re-dispatch may follow (visible, bounded). +- **`mpk:` key refs** (`[keyproxy]`): resolved through the + ukrrs/mopac-keyproxy hop โ€” POST `/v1/resolve`, bearer token from a + LOCAL `token_ref` (no recursion), 60s in-memory cache, transport + errors query-string-stripped so a misconfigured hop cannot echo the + token. `env:`/`file:`/`literal:` refs untouched: the loop runs with or + without keyproxy up. All hosts in harness.toml, none in code. +- **Gitea REPORT commit** (optional, off by default): `[gitea] + commit_reports = true` commits each REPORT file right after it lands + (contents API: GET for sha โ†’ POST create / PUT update, `token` auth, + branch from config). +- **Module rename**: `git.knownelement.com/reachableceo/MOPAC/harness` โ†’ + `ukrrs.com/mopac/harness` (the doc-rot finding folded in; build + vet + + test verified in the Docker builder). +- **Tests**: loop e2e against a fake Redmine (mutable state: notes bump + updated_on like the real SoR) + fake OpenAI server โ€” dispatch/note/ + status happy path, dedup on rescan, re-dispatch on PMO issue update, + failed turn recorded without hot-loop, dry-run zero-trace, restart + dedup survival, status-map miss, gitea create/update/auth shapes, + keyproxy resolver (cache hit, auto-prefix, 401/404, local refs + untouched), writeback unit tests for PUT body shapes. + +Replaced by this turn (die list for the PMO report; deletion pending +Charles' sign-off): `/home/_crossfeed/tooling/agent-stack/semaphore.sh` + +slot-file cap; `~/.coordinate/scripts/queue-next.sh`, `queue-after.sh`, +`pmo-heartbeat.sh`; the dispatch-turn.sh screen doorbell (already dead โ€” +it targets screen `reachableceo-PMO`, the live screen is `RCEO-PMO`, so +the message was silently dropped). + ## Next chunk (phase 3) 1. bitwarden-go wrapper + `bw:` refs (unblocks secret posture). 2. Full permission layer: tree-sitter bash scopes, write roots, symlink checks, per-vertical allow/deny presets. -3. Redmine note writeback + status transition so chaining advances scope. -4. Event โ†’ turn dispatch wiring: stored actionable events actually chain +3. Event โ†’ turn dispatch wiring: stored actionable events actually chain conductor iterations for the affected stack. -5. Budget gate via LiteLLM spend APIs; cost line in REPORT. -6. Streaming with truncation retry + turn resume. +4. Budget gate via LiteLLM spend APIs; cost line in REPORT. +5. Streaming with truncation retry + turn resume. +6. Loop concurrency knob (in-process limit) once load justifies it. diff --git a/cmd/harness/main.go b/cmd/harness/main.go index cfcef86..d346b96 100644 --- a/cmd/harness/main.go +++ b/cmd/harness/main.go @@ -176,7 +176,12 @@ func runLoop(args []string) int { DryRun: *dryRun, }); err != nil { fmt.Fprintf(os.Stderr, "harness: %v\n", err) - return 1 + switch { + case errors.Is(err, loop.ErrIntake): + return 2 + default: + return 1 + } } return 0 } diff --git a/dev.sh b/dev.sh index abced0e..29bac0d 100755 --- a/dev.sh +++ b/dev.sh @@ -3,13 +3,16 @@ # digest-pinned Docker builder (DESIGN "ALL dev work in Docker" โ€” big rule); # the host runs containers, never toolchains. # -# Usage: ./dev.sh {build|vet|test|check|events|smoke|shell} [args...] +# Usage: ./dev.sh {build|vet|test|check|events|loop|smoke|shell} [args...] # # build compile ./cmd/harness into bin/harness # vet go vet ./... # test go test ./... # check build + vet + test (the pre-commit gate) # events run `harness events` with host port 4100 published (LAN smoke) +# loop run `harness loop` (the self-host daemon; repo bind-mounted so +# REPORTs + state/loop/loop.jsonl land in the repo; key env vars +# passed through; args go to the loop, e.g. `./dev.sh loop --once`) # smoke full webhook smoke: starts events in a container, POSTs via # python urllib (curl is banned on host), shows 401/200 + JSONL, # tears the container down @@ -48,6 +51,16 @@ events) -e HARNESS_GITEA_WEBHOOK_SECRET \ "$IMAGE" /h/bin/harness events -listen ":4100" "$@" ;; +loop) + # Self-host daemon: repo bind-mounted (REPORTs + loop.jsonl land in the + # repo); key refs' env vars passed through. Stop with SIGINT/double Ctrl-C + # or `docker stop mopac-loop`. + exec docker run --rm -i --name mopac-loop \ + -v "$PWD:/h" -w /h -u "$(id -u):$(id -g)" -e HOME=/tmp \ + -e HARNESS_REDMINE_KEY -e HARNESS_LITELLM_KEY -e HARNESS_GITEA_KEY \ + -e HARNESS_KEYPROXY_TOKEN \ + "$IMAGE" /h/bin/harness loop "$@" + ;; smoke) ./smoke/smoke.sh ;; @@ -55,7 +68,7 @@ shell) run sh ;; *) - echo "dev.sh: unknown command $cmd (build|vet|test|check|events|smoke|shell)" >&2 + echo "dev.sh: unknown command $cmd (build|vet|test|check|events|loop|smoke|shell)" >&2 exit 1 ;; esac diff --git a/internal/loop/daemon.go b/internal/loop/daemon.go index 8402ae6..84e0bac 100644 --- a/internal/loop/daemon.go +++ b/internal/loop/daemon.go @@ -74,13 +74,19 @@ func (c *Conductor) RunLoop(ctx context.Context, opts LoopOpts) error { } for { - if err := c.scanOnce(ctx, state, writer, gitea, opts.DryRun); err != nil { + scanErr := c.scanOnce(ctx, state, writer, gitea, opts.DryRun) + if scanErr != nil { // Scan failures (redmine down, decode hiccups) are logged and // retried on the next tick; only ctx cancel ends the daemon. - fmt.Fprintf(c.out, "harness: loop: scan error: %v (retrying next interval)\n", err) - _ = state.log(loopEvent{Type: evError, Detail: "scan: " + err.Error()}) + fmt.Fprintf(c.out, "harness: loop: scan error: %v (retrying next interval)\n", scanErr) + _ = state.log(loopEvent{Type: evError, Detail: "scan: " + scanErr.Error()}) } if opts.Once { + if scanErr != nil { + // A cron-style scan that could not even read the intake + // must fail loudly, not exit 0. + return scanErr + } return nil } select {