Files
MOPAC/internal/loop/daemon.go
T
mrcharles ecc0ee874b 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
2026-08-28 22:17:03 -05:00

251 lines
8.9 KiB
Go

// Self-host daemon: `harness loop` polls the Redmine intake on an interval,
// runs ONE bounded turn per new/updated issue (sequentially, v0), notes the
// REPORT back on the issue, transitions status per the config map, and
// optionally commits the REPORT to gitea. Redmine is the SoR; this loop is
// the worker. It replaces the crossfeed bash stack: no slot files, no
// doorbell screens, no queue scripts.
package loop
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"ukrrs.com/mopac/harness/internal/models"
"ukrrs.com/mopac/harness/internal/task"
"ukrrs.com/mopac/harness/internal/writeback"
)
// LoopOpts controls the daemon.
type LoopOpts struct {
Interval time.Duration // poll interval; 0 = [loop] poll_interval_secs
Once bool // single scan then return (cron-able)
DryRun bool // scan + print what would dispatch; no turns, no state writes
}
// RunLoop is the daemon body. It returns nil on context cancel (SIGINT),
// and a non-nil error only for startup/config failures.
func (c *Conductor) RunLoop(ctx context.Context, opts LoopOpts) error {
rc := c.cfg.Redmine
if rc.URL == "" {
return fmt.Errorf("loop: [redmine] url is required (the loop is Redmine-driven; use `harness once --demo` for the demo issue)")
}
interval := opts.Interval
if interval <= 0 {
interval = time.Duration(c.cfg.Loop.PollIntervalSecs) * time.Second
}
var state *loopState
var err error
if opts.DryRun {
// Dry-run: dedup awareness without leaving any trace on disk.
state, err = openLoopStateReadOnly(c.cfg.Loop.StateDir)
} else {
state, err = openLoopState(c.cfg.Loop.StateDir)
}
if err != nil {
return err
}
defer state.Close()
key, err := c.keys.Resolve(ctx, rc.KeyRef)
if err != nil {
return fmt.Errorf("loop: redmine key: %w", err)
}
writer := writeback.NewRedmineWriter(rc, key)
var gitea *writeback.GiteaWriter
if c.cfg.Gitea.CommitReports {
gkey, err := c.keys.Resolve(ctx, c.cfg.Gitea.KeyRef)
if err != nil {
return fmt.Errorf("loop: gitea key: %w", err)
}
gitea = writeback.NewGiteaWriter(c.cfg.Gitea, gkey)
}
fmt.Fprintf(c.out, "harness: loop: vertical=%s poll=%s state=%s redmine=%s (SIGINT to stop)\n",
c.cfg.Vertical, interval, c.cfg.Loop.StateDir, c.redmineHost())
if gitea != nil {
fmt.Fprintf(c.out, "harness: loop: gitea report commit on (%s/%s)\n", c.cfg.Gitea.Owner, c.cfg.Gitea.Repo)
}
for {
if err := c.scanOnce(ctx, state, writer, gitea, opts.DryRun); err != 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()})
}
if opts.Once {
return nil
}
select {
case <-ctx.Done():
fmt.Fprintf(c.out, "harness: loop: stopped\n")
return nil
case <-time.After(interval):
}
}
}
// scanOnce runs one intake scan: every task whose (id, updated_on) has not
// been processed gets one bounded turn + writebacks, strictly sequentially.
func (c *Conductor) scanOnce(ctx context.Context, state *loopState, writer *writeback.RedmineWriter, gitea *writeback.GiteaWriter, dryRun bool) error {
tasks, err := c.intake(ctx, OnceOpts{})
if err != nil {
return err
}
fresh := 0
for _, t := range tasks {
if !state.processed(t.ID, t.UpdatedOn) {
fresh++
}
}
fmt.Fprintf(c.out, "harness: loop: scan: %d task(s) in scope, %d new/updated\n", len(tasks), fresh)
for _, t := range tasks {
if state.processed(t.ID, t.UpdatedOn) {
continue
}
if dryRun {
fmt.Fprintf(c.out, "harness: loop: would dispatch %s (updated %s): %q\n", t.ID, t.UpdatedOn, t.Subject)
continue
}
c.dispatchTask(ctx, state, writer, gitea, t)
}
return nil
}
// dispatchTask runs one turn end-to-end for t: mark -> turn -> REPORT ->
// note -> status -> gitea -> refresh. Every step logs one stdout line plus
// one JSONL event; a failed step never kills the loop.
func (c *Conductor) dispatchTask(ctx context.Context, state *loopState, writer *writeback.RedmineWriter, gitea *writeback.GiteaWriter, t task.Task) {
// Mark BEFORE the turn: a failing turn must not hot-loop the poll.
_ = state.log(loopEvent{Type: evDispatch, TaskID: t.ID, UpdatedOn: t.UpdatedOn, Subject: t.Subject})
fmt.Fprintf(c.out, "harness: loop: dispatch %s (updated %s): %q\n", t.ID, t.UpdatedOn, t.Subject)
run, err := c.runTask(ctx, t)
if run != nil && run.turn != nil && run.reportPath != "" {
_ = state.log(loopEvent{
Type: evReport, TaskID: t.ID, Model: run.decision.Model,
ReportPath: run.reportPath, StopReason: run.turn.StopReason,
})
}
if err != nil {
_ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "turn: " + err.Error()})
fmt.Fprintf(c.out, "harness: loop: task %s turn failed: %v (issue left for PMO; update the issue to retry)\n", t.ID, err)
return
}
// Gitea commit (optional): the REPORT file into the repo.
if gitea != nil && run.reportPath != "" {
if err := gitea.CommitFile(ctx, repoPath(run.reportPath), run.reportBody,
fmt.Sprintf("harness: REPORT for %s task %s (%s)", c.cfg.Vertical, t.ID, run.decision.Model)); err != nil {
_ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "gitea commit: " + err.Error()})
fmt.Fprintf(c.out, "harness: loop: gitea commit failed for %s: %v\n", t.ID, err)
} else {
_ = state.log(loopEvent{Type: evCommit, TaskID: t.ID, ReportPath: run.reportPath})
fmt.Fprintf(c.out, "harness: loop: gitea: committed %s\n", repoPath(run.reportPath))
}
}
// SoR writeback: note the REPORT on the issue, then transition status.
if to, ok := c.cfg.Redmine.StatusMap[t.Status]; ok {
if err := writer.SetStatus(ctx, t.ID, to); err != nil {
_ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "status: " + err.Error()})
fmt.Fprintf(c.out, "harness: loop: status transition failed for %s: %v\n", t.ID, err)
} else {
_ = state.log(loopEvent{Type: evStatus, TaskID: t.ID, StatusFrom: t.Status, StatusTo: to})
fmt.Fprintf(c.out, "harness: loop: status %s -> %s on #%s\n", t.Status, to, t.ID)
}
}
if err := writer.AddNote(ctx, t.ID, run.reportBody); err != nil {
_ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "note: " + err.Error()})
fmt.Fprintf(c.out, "harness: loop: note writeback failed for %s: %v\n", t.ID, err)
} else {
_ = state.log(loopEvent{Type: evNote, TaskID: t.ID, ReportPath: run.reportPath})
fmt.Fprintf(c.out, "harness: loop: noted REPORT on #%s\n", t.ID)
}
// Our own writebacks bump updated_on; refresh the dedup marker to the
// post-writeback value so the next scan does not re-trigger itself.
updated, err := writer.UpdatedOn(ctx, t.ID)
if err != nil {
_ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "refresh: " + err.Error()})
fmt.Fprintf(c.out, "harness: loop: refresh failed for %s: %v (a re-dispatch may follow)\n", t.ID, err)
return
}
_ = state.log(loopEvent{Type: evRefresh, TaskID: t.ID, UpdatedOn: updated})
}
// taskRun is one completed (or failed) turn with everything the writebacks
// need: the routing decision, the turn result, and the REPORT file path +
// rendered body.
type taskRun struct {
task task.Task
decision models.Decision
turn *TurnResult
reportPath string
reportBody string
}
// runTask is the shared per-task core of `once` and `loop`: routing ->
// bounded turn -> REPORT file. The returned error is the turn error (a
// partial REPORT may still exist); setup failures (routing, keys) return a
// nil taskRun.
func (c *Conductor) runTask(ctx context.Context, t task.Task) (*taskRun, error) {
decision, err := c.router.Resolve(t.Class)
if err != nil {
return nil, err
}
fmt.Fprintf(c.out, "harness: task %s (%s): %q class=%s -> %s -> %s\n",
t.ID, t.Source, t.Subject, t.Class, decision.Tier, decision.Model)
client, err := c.llmClient()
if err != nil {
return nil, err
}
start := time.Now()
turn, err := c.turn(ctx, client, t, decision.Model)
run := &taskRun{task: t, decision: decision, turn: turn}
if err != nil {
if turn != nil && turn.Content != "" {
path, body, werr := c.writeReport(t, decision, turn, start, err)
if werr == nil {
run.reportPath, run.reportBody = path, body
}
}
return run, fmt.Errorf("%w: %v", ErrLLM, err)
}
path, body, werr := c.writeReport(t, decision, turn, start, nil)
if werr != nil {
return run, werr
}
run.reportPath, run.reportBody = path, body
return run, nil
}
// repoPath maps a local REPORT path to its in-repo path (slash-separated,
// relative to the process CWD when possible, else the base name).
func repoPath(local string) string {
cwd, err := os.Getwd()
if err == nil {
if rel, err := filepath.Rel(cwd, local); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return filepath.ToSlash(rel)
}
}
return filepath.ToSlash(filepath.Base(local))
}
func (c *Conductor) redmineHost() string {
u := c.cfg.Redmine.URL
if i := strings.Index(u, "://"); i >= 0 {
u = u[i+3:]
}
return strings.TrimSuffix(u, "/")
}