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:
+65
-7
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user