The loop now consults quota and host state before every dispatch and
DEFERS gated work with a logged reason instead of letting turns die at
the provider (the 2026-08-28 19:00 quota-wall failure mode, replayed as
a test). Adds internal/quota: 5h/weekly credit buckets (provider poll
when z.ai ships an endpoint - fake-server tested - else locally
estimated from the documented credit formula), TZ-aware peak window
(default 01:00-05:00 America/Chicago weekdays, matching the documented
z.ai peak Mon-Fri 14:00-18:00 Singapore), block/defer thresholds, a
read-only load/mem/disk/IO-PSI monitor, an optional redis shared-state
hop (stdlib RESP2 mini-client) so all instances of an account
coordinate, per-class token+credit accounting in loop.jsonl, and
`harness quota status|probe|gate`. Config: [quota] + [resources]
sections; README runbook covers the redis container and deploy-time
cgroup enforcement.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
282 lines
10 KiB
Go
282 lines
10 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/quota"
|
|
"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 status := c.gateStatusLine(ctx); status != "off" {
|
|
fmt.Fprintf(c.out, "harness: loop: gate: %s\n", status)
|
|
}
|
|
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 {
|
|
scanErr := c.scanOnce(ctx, state, writer, gitea, opts.DryRun)
|
|
if scanErr != 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", scanErr)
|
|
_ = state.log(loopEvent{Type: evError, Detail: "scan: " + scanErr.Error()})
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
// Back-pressure: quota buckets + peak schedule + host resources. A
|
|
// defer skips the turn WITHOUT consuming the task — the next scan
|
|
// reconsiders it (the 19:00-wall class of failure becomes a logged
|
|
// throttle instead of dead turns).
|
|
if !c.gateConsult(ctx, state, t) {
|
|
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 != "" {
|
|
// Usage accounting: tokens + estimated z.ai credits, per class,
|
|
// into the JSONL (feeds the Discourse usage reports) and the
|
|
// shared quota state (all instances' estimates).
|
|
credits := 0.0
|
|
if c.gate != nil {
|
|
credits = c.gate.RecordTurn(quota.EstimateTurnInput{
|
|
Model: run.decision.Model,
|
|
PromptTokens: run.turn.PromptTokens,
|
|
CompletionTokens: run.turn.CompletionTokens,
|
|
Peak: c.gate.InPeak(),
|
|
})
|
|
}
|
|
_ = state.log(loopEvent{
|
|
Type: evReport, TaskID: t.ID, Model: run.decision.Model, Class: t.Class,
|
|
ReportPath: run.reportPath, StopReason: run.turn.StopReason,
|
|
PromptTokens: run.turn.PromptTokens, CompletionTokens: run.turn.CompletionTokens,
|
|
TotalTokens: run.turn.TotalTokens, Credits: credits,
|
|
})
|
|
}
|
|
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, "/")
|
|
}
|