// 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, "/") }