docs + dev.sh: loop in README/REPORT, ./dev.sh loop runner, scan-failure exit codes
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
This commit is contained in:
@@ -3,3 +3,4 @@ reports/
|
|||||||
bin/
|
bin/
|
||||||
*.test
|
*.test
|
||||||
.smoke/
|
.smoke/
|
||||||
|
state/
|
||||||
|
|||||||
@@ -3,14 +3,17 @@
|
|||||||
The MOPAC harness is a headless agent conductor written in Go: it pulls a task
|
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,
|
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
|
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
|
a REPORT file — with `harness loop` it also drives ITSELF: Redmine is the SoR,
|
||||||
re-invoking `harness once`. It is the execution core for the org's vertical
|
the loop is the worker, no bash middle layer. There is no TUI — humans and
|
||||||
stacks: humans and other agents interact with a stack only through Redmine
|
other agents interact with a stack only through Redmine (SoR), Discourse
|
||||||
(SoR), Discourse (docs) and Gitea (code), never by attaching to the loop.
|
(docs) and Gitea (code), never by attaching to the loop.
|
||||||
|
|
||||||
Status: 2026-08-28 — skeleton + event receiver; MVP demo path live (single
|
Status: 2026-08-28 — skeleton + event receiver + **self-host loop live**:
|
||||||
GLM turn through LiteLLM to REPORT, proven live 2026-08-28); `harness
|
`harness loop` polls the intake, runs one bounded turn per new/updated issue,
|
||||||
events` webhook receiver smoke-proven on LAN port 4100 the same day.
|
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
|
## 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
|
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.
|
`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`)
|
### Webhook receiver (`harness events`)
|
||||||
|
|
||||||
Redmine / Discourse / Gitea webhooks punch the harness through the event
|
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
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
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:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
S(["harness once"]) --> I["INTAKE<br/>Redmine /issues.json scope query<br/>or the [demo] issue"]
|
S(["harness once"]) --> I["INTAKE<br/>Redmine /issues.json scope query<br/>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
|
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
|
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
|
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:
|
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 help` | print usage (also `-h`, `--help`) |
|
||||||
| `harness once` | run ONE conductor iteration, then exit |
|
| `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 |
|
| `harness events` | run the webhook receiver until SIGINT/SIGTERM |
|
||||||
|
|
||||||
Flags for `once`:
|
Flags for `once`:
|
||||||
@@ -202,6 +267,15 @@ Flags for `once`:
|
|||||||
| `-demo` | run the `[demo]` issue instead of Redmine intake |
|
| `-demo` | run the `[demo]` issue instead of Redmine intake |
|
||||||
| `-task-id ID` | run only the task/issue with this id |
|
| `-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`:
|
Flags for `events`:
|
||||||
|
|
||||||
| Flag | Meaning |
|
| Flag | Meaning |
|
||||||
@@ -213,7 +287,7 @@ Exit codes:
|
|||||||
|
|
||||||
| Code | Meaning |
|
| 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 |
|
| 1 | usage / config / routing / writeback error |
|
||||||
| 2 | intake error |
|
| 2 | intake error |
|
||||||
| 4 | llm / turn error |
|
| 4 | llm / turn error |
|
||||||
@@ -229,12 +303,19 @@ cannot rot):
|
|||||||
| | `work_root` | where the bash tool executes (default `.`) |
|
| | `work_root` | where the bash tool executes (default `.`) |
|
||||||
| | `report_dir` | where REPORT files land (default `reports`) |
|
| | `report_dir` | where REPORT files land (default `reports`) |
|
||||||
| `[loop]` | `max_rounds` | max LLM round trips per turn, tool calls included (default 8) |
|
| `[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 |
|
| `[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) |
|
| | `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`) |
|
| | `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) |
|
| | `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 |
|
| `[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) |
|
| | `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]` | tier keys, `default_tier` | tier alias → concrete proxy model (see below) |
|
||||||
| `[models.classes]` | class keys | task class → tier alias (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) |
|
| `[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) |
|
| `[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`,
|
Key refs accepted anywhere a `*_ref` appears: `env:NAME`, `file:PATH`,
|
||||||
`literal:VALUE` (last resort), and `bw:REF` (reserved; errors until the
|
`literal:VALUE` (last resort), `mpk:PLACEHOLDER` (resolved through the
|
||||||
bitwarden wrapper lands in build phase 3).
|
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
|
## 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 |
|
| Exec tool | Works | allow-listed bash, segment-wise compound checks, timeout + truncation |
|
||||||
| REPORT writeback | Works | timestamped file + `REPORT-latest.md`, atomic, full telemetry |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Budget/semaphore gate | Stubbed | tokens in REPORT, no cost/spend enforcement yet |
|
||||||
| `bw:` key refs | Stubbed | error until the bitwarden wrapper (phase 3) |
|
| `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 |
|
| Streaming + turn resume | Stubbed | retry is request-level today |
|
||||||
| Session persistence/repair | Stubbed | not started |
|
| Session persistence/repair | Stubbed | not started |
|
||||||
| Write confinement | Stubbed | declared roots + symlink checks land with the permission layer |
|
| 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 |
|
| 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
|
## Docs and links
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- Event → turn dispatch: `harness events` stores + maps events and hands
|
||||||
them to `Conductor.DispatchEvent`, which is a printing stub (phase 3).
|
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
|
- Budget/semaphore GATE (LiteLLM spend APIs, class-aware slots) and cost
|
||||||
in REPORT (tokens only today).
|
in REPORT (tokens only today).
|
||||||
- `bw:` key refs error until the bitwarden wrapper (phase 3).
|
- `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.
|
- Session persistence + read-time repair (crush notes §2) — not started.
|
||||||
- Write confinement to declared roots + symlink-aware canonicalization;
|
- Write confinement to declared roots + symlink-aware canonicalization;
|
||||||
redirects currently match by segment text (phase 3 permission layer).
|
redirects currently match by segment text (phase 3 permission layer).
|
||||||
- Task selection is first-in-scope; no P1-first ordering or status
|
- `once` task selection is first-in-scope (`--task-id` to chain); the
|
||||||
transitions, so chained `once` re-picks the same issue (use --task-id).
|
loop's dedup + status map supersede this for self-hosted runs.
|
||||||
- Local inbox intake (DESIGN core-loop step 1, second half) — not started.
|
- Local inbox intake (DESIGN core-loop step 1, second half) — not started.
|
||||||
|
|
||||||
## Build phase 2b — events receiver (`mopac events`) — 2026-08-28
|
## Build phase 2b — events receiver (`mopac events`) — 2026-08-28
|
||||||
@@ -127,13 +126,78 @@ Stubbed:
|
|||||||
action; real wiring lands in phase 3 per plan).
|
action; real wiring lands in phase 3 per plan).
|
||||||
- Cross-host/event forwarding (T1 replication later; JSONL is local-only).
|
- 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)
|
## Next chunk (phase 3)
|
||||||
|
|
||||||
1. bitwarden-go wrapper + `bw:` refs (unblocks secret posture).
|
1. bitwarden-go wrapper + `bw:` refs (unblocks secret posture).
|
||||||
2. Full permission layer: tree-sitter bash scopes, write roots, symlink
|
2. Full permission layer: tree-sitter bash scopes, write roots, symlink
|
||||||
checks, per-vertical allow/deny presets.
|
checks, per-vertical allow/deny presets.
|
||||||
3. Redmine note writeback + status transition so chaining advances scope.
|
3. Event → turn dispatch wiring: stored actionable events actually chain
|
||||||
4. Event → turn dispatch wiring: stored actionable events actually chain
|
|
||||||
conductor iterations for the affected stack.
|
conductor iterations for the affected stack.
|
||||||
5. Budget gate via LiteLLM spend APIs; cost line in REPORT.
|
4. Budget gate via LiteLLM spend APIs; cost line in REPORT.
|
||||||
6. Streaming with truncation retry + turn resume.
|
5. Streaming with truncation retry + turn resume.
|
||||||
|
6. Loop concurrency knob (in-process limit) once load justifies it.
|
||||||
|
|||||||
+6
-1
@@ -176,7 +176,12 @@ func runLoop(args []string) int {
|
|||||||
DryRun: *dryRun,
|
DryRun: *dryRun,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "harness: %v\n", err)
|
fmt.Fprintf(os.Stderr, "harness: %v\n", err)
|
||||||
return 1
|
switch {
|
||||||
|
case errors.Is(err, loop.ErrIntake):
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
# digest-pinned Docker builder (DESIGN "ALL dev work in Docker" — big rule);
|
# digest-pinned Docker builder (DESIGN "ALL dev work in Docker" — big rule);
|
||||||
# the host runs containers, never toolchains.
|
# 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
|
# build compile ./cmd/harness into bin/harness
|
||||||
# vet go vet ./...
|
# vet go vet ./...
|
||||||
# test go test ./...
|
# test go test ./...
|
||||||
# check build + vet + test (the pre-commit gate)
|
# check build + vet + test (the pre-commit gate)
|
||||||
# events run `harness events` with host port 4100 published (LAN smoke)
|
# 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
|
# smoke full webhook smoke: starts events in a container, POSTs via
|
||||||
# python urllib (curl is banned on host), shows 401/200 + JSONL,
|
# python urllib (curl is banned on host), shows 401/200 + JSONL,
|
||||||
# tears the container down
|
# tears the container down
|
||||||
@@ -48,6 +51,16 @@ events)
|
|||||||
-e HARNESS_GITEA_WEBHOOK_SECRET \
|
-e HARNESS_GITEA_WEBHOOK_SECRET \
|
||||||
"$IMAGE" /h/bin/harness events -listen ":4100" "$@"
|
"$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/smoke.sh
|
./smoke/smoke.sh
|
||||||
;;
|
;;
|
||||||
@@ -55,7 +68,7 @@ shell)
|
|||||||
run sh
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -74,13 +74,19 @@ func (c *Conductor) RunLoop(ctx context.Context, opts LoopOpts) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for {
|
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
|
// Scan failures (redmine down, decode hiccups) are logged and
|
||||||
// retried on the next tick; only ctx cancel ends the daemon.
|
// 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)
|
fmt.Fprintf(c.out, "harness: loop: scan error: %v (retrying next interval)\n", scanErr)
|
||||||
_ = state.log(loopEvent{Type: evError, Detail: "scan: " + err.Error()})
|
_ = state.log(loopEvent{Type: evError, Detail: "scan: " + scanErr.Error()})
|
||||||
}
|
}
|
||||||
if opts.Once {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
Reference in New Issue
Block a user