harness loop: self-hosting daemon — Redmine SoR drives its own turns

`harness loop` polls the /issues.json intake on an interval (default
120s, --once for cron-style single scans), runs one bounded turn per
new/updated issue — deduped by issue id + updated_on in an append-only
loop.jsonl — then writes the REPORT back as a Redmine journal note,
transitions status per [redmine.status_map] (In Progress -> Done by
name, resolved via /issue_statuses.json), and refreshes the dedup marker
to the post-writeback updated_on so its own notes never re-trigger it.
Turns are sequential (v0); failed turns are recorded, not retried, so a
down proxy cannot hot-loop the poll. The optional Gitea REPORT commit
([gitea] commit_reports, off) rides the same flow. No slot files, no
doorbell screens, no queue scripts — the bash middle layer is replaced,
not wrapped.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-28 22:17:03 -05:00
parent c869ac1b06
commit ecc0ee874b
10 changed files with 1359 additions and 22 deletions
+65 -7
View File
@@ -1,5 +1,5 @@
// Command harness is the MOPAC harness CLI. v0 surface: `harness once`,
// `harness events`.
// `harness loop`, `harness events`.
package main
import (
@@ -22,26 +22,37 @@ const usage = `MOPAC harness (v0)
Usage:
harness once [-config PATH] [-dry-run] [-demo] [-task-id ID]
harness loop [-config PATH] [-interval DUR] [-once] [-dry-run]
harness events [-config PATH] [-listen ADDR]
once runs ONE conductor iteration and exits (chain by re-invoking; no daemon):
intake (Redmine scope, or the [demo] issue) -> plan/model routing ->
bounded turn via LiteLLM -> REPORT file writeback.
loop is the self-hosting daemon: Redmine is the SoR, the loop is the worker.
It polls the intake on an interval, runs ONE bounded turn per new/updated
issue (sequentially), notes the REPORT back on the issue, transitions
status per [redmine.status_map], optionally commits the REPORT to gitea
([gitea] commit_reports). State is append-only loop.jsonl, dedup by
issue id + updated_on. --once = single scan (cron-able). SIGINT stops.
events runs the webhook receiver until SIGINT/SIGTERM: Redmine/Discourse/
Gitea webhooks are verified, normalized, stored append-only (dedup by
provider event id) and handed to the conductor (dispatch stub for now).
Routes: POST /hooks/{redmine,discourse,gitea}, GET /healthz.
Flags:
-config PATH config file (default $HARNESS_CONFIG or ./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
-listen ADDR events: bind address (overrides [events] listen)
-config PATH config file (default $HARNESS_CONFIG or ./harness.toml)
-dry-run once/loop: intake + plan only; no LLM call, no REPORT,
no state writes
-demo once: run the [demo] issue instead of Redmine intake
-task-id ID once: run only the task/issue with this id
-interval DUR loop: poll interval (overrides [loop] poll_interval_secs)
-once loop: single scan then exit (cron-able)
-listen ADDR events: bind address (overrides [events] listen)
Exit codes:
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
@@ -62,6 +73,8 @@ func run(args []string) int {
return 0
case "once":
return runOnce(args[1:])
case "loop":
return runLoop(args[1:])
case "events":
return runEvents(args[1:])
default:
@@ -123,6 +136,51 @@ func runOnce(args []string) int {
return 0
}
func runLoop(args []string) int {
fs := flag.NewFlagSet("loop", flag.ContinueOnError)
cfgPath := fs.String("config", "", "config file path")
interval := fs.Duration("interval", 0, "poll interval (overrides [loop] poll_interval_secs)")
once := fs.Bool("once", false, "single scan then exit (cron-able)")
dryRun := fs.Bool("dry-run", false, "scan and print what would dispatch; no turns, no state writes")
if err := fs.Parse(args); err != nil {
return 1
}
if fs.NArg() > 0 {
fmt.Fprintf(os.Stderr, "harness: unexpected argument %q\n", fs.Arg(0))
return 1
}
if *cfgPath == "" {
*cfgPath = os.Getenv("HARNESS_CONFIG")
}
if *cfgPath == "" {
*cfgPath = "harness.toml"
}
cfg, err := config.Load(*cfgPath)
if err != nil {
fmt.Fprintf(os.Stderr, "harness: %v\n", err)
return 1
}
conductor, err := loop.New(cfg, os.Stdout)
if err != nil {
fmt.Fprintf(os.Stderr, "harness: %v\n", err)
return 1
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := conductor.RunLoop(ctx, loop.LoopOpts{
Interval: *interval,
Once: *once,
DryRun: *dryRun,
}); err != nil {
fmt.Fprintf(os.Stderr, "harness: %v\n", err)
return 1
}
return 0
}
func runEvents(args []string) int {
fs := flag.NewFlagSet("events", flag.ContinueOnError)
cfgPath := fs.String("config", "", "config file path")