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:
@@ -0,0 +1,415 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ukrrs.com/mopac/harness/internal/config"
|
||||
)
|
||||
|
||||
// fakeRedmine is a mutable in-memory Redmine: /issues.json scope listing,
|
||||
// PUT /issues/{id}.json (note + status), /issue_statuses.json, and
|
||||
// GET /issues/{id}.json (updated_on). Notes bump updated_on like the real
|
||||
// SoR, so the loop's refresh logic is exercised for real.
|
||||
type fakeRedmine struct {
|
||||
mu sync.Mutex
|
||||
issues map[string]struct {
|
||||
subject string
|
||||
status string
|
||||
updatedOn string
|
||||
notes []string
|
||||
}
|
||||
statusIDs map[string]int
|
||||
noted []string // issue ids that got a note, in order
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newFakeRedmine(t *testing.T) *fakeRedmine {
|
||||
t.Helper()
|
||||
f := &fakeRedmine{
|
||||
issues: map[string]struct {
|
||||
subject string
|
||||
status string
|
||||
updatedOn string
|
||||
notes []string
|
||||
}{},
|
||||
statusIDs: map[string]int{"New": 1, "In Progress": 2, "Done": 3},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /issues.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"issues":[`)
|
||||
first := true
|
||||
for id, is := range f.issues {
|
||||
if !first {
|
||||
b.WriteString(",")
|
||||
}
|
||||
first = false
|
||||
fmt.Fprintf(&b, `{"id":%s,"subject":%q,"description":"do the thing","updated_on":%q,"status":{"name":%q},"custom_fields":[{"name":"Class","value":"primary"}]}`,
|
||||
id, is.subject, is.updatedOn, is.status)
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(b.String()))
|
||||
})
|
||||
mux.HandleFunc("PUT /issues/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/issues/"), ".json")
|
||||
var body struct {
|
||||
Issue struct {
|
||||
Notes string `json:"notes"`
|
||||
StatusID int `json:"status_id"`
|
||||
} `json:"issue"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
is, ok := f.issues[id]
|
||||
if !ok {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if body.Issue.Notes != "" {
|
||||
is.notes = append(is.notes, body.Issue.Notes)
|
||||
f.noted = append(f.noted, id)
|
||||
}
|
||||
if body.Issue.StatusID != 0 {
|
||||
for name, sid := range f.statusIDs {
|
||||
if sid == body.Issue.StatusID {
|
||||
is.status = name
|
||||
}
|
||||
}
|
||||
}
|
||||
is.updatedOn = time.Now().UTC().Format(time.RFC3339)
|
||||
f.issues[id] = is
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("GET /issue_statuses.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"issue_statuses":[`)
|
||||
first := true
|
||||
for name, id := range f.statusIDs {
|
||||
if !first {
|
||||
b.WriteString(",")
|
||||
}
|
||||
first = false
|
||||
fmt.Fprintf(&b, `{"id":%d,"name":%q}`, id, name)
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(b.String()))
|
||||
})
|
||||
mux.HandleFunc("GET /issues/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/issues/"), ".json")
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
is, ok := f.issues[id]
|
||||
if !ok {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `{"issue":{"id":%s,"updated_on":%q}}`, id, is.updatedOn)
|
||||
})
|
||||
f.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeRedmine) addIssue(id, subject, status, updatedOn string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.issues[id] = struct {
|
||||
subject string
|
||||
status string
|
||||
updatedOn string
|
||||
notes []string
|
||||
}{subject: subject, status: status, updatedOn: updatedOn}
|
||||
}
|
||||
|
||||
func (f *fakeRedmine) setUpdatedOn(id, updatedOn string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if is, ok := f.issues[id]; ok {
|
||||
is.updatedOn = updatedOn
|
||||
f.issues[id] = is
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeRedmine) status(id string) string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.issues[id].status
|
||||
}
|
||||
|
||||
func (f *fakeRedmine) noteCount(id string) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.issues[id].notes)
|
||||
}
|
||||
|
||||
func loopTestConfig(t *testing.T, redmineURL, llmURL string) *config.Config {
|
||||
t.Helper()
|
||||
cfg := testConfig(t, llmURL)
|
||||
cfg.Redmine.URL = redmineURL
|
||||
cfg.Redmine.KeyRef = "literal:rm-key"
|
||||
cfg.Redmine.ScopeQuery = "project=mopac"
|
||||
cfg.Redmine.StatusMap = map[string]string{"In Progress": "Done"}
|
||||
cfg.Loop.StateDir = filepath.Join(t.TempDir(), "state-loop")
|
||||
cfg.Loop.PollIntervalSecs = 3600 // between-scan sleep; tests use --once scans
|
||||
return cfg
|
||||
}
|
||||
|
||||
// runOneScan runs the daemon in --once mode against the given servers.
|
||||
func runOneScan(t *testing.T, cfg *config.Config, dryRun bool) string {
|
||||
t.Helper()
|
||||
var out strings.Builder
|
||||
cond, err := New(cfg, &out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cond.RunLoop(context.Background(), LoopOpts{Once: true, DryRun: dryRun}); err != nil {
|
||||
t.Fatalf("RunLoop: %v (output:\n%s)", err, out.String())
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func TestLoopDispatchNoteStatusDedup(t *testing.T) {
|
||||
rm := newFakeRedmine(t)
|
||||
rm.addIssue("421", "Self-host: write the note", "In Progress", "2026-08-28T21:30:00Z")
|
||||
f := newFakeLLM(t, textMsg("Report body: the turn ran."))
|
||||
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
||||
|
||||
out := runOneScan(t, cfg, false)
|
||||
|
||||
// One LLM turn, routed through the class map.
|
||||
if f.requestCount() != 1 {
|
||||
t.Fatalf("LLM calls = %d, want 1", f.requestCount())
|
||||
}
|
||||
for _, want := range []string{
|
||||
"dispatch 421",
|
||||
"noted REPORT on #421",
|
||||
"status In Progress -> Done on #421",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if rm.status("421") != "Done" {
|
||||
t.Errorf("issue status = %q, want Done", rm.status("421"))
|
||||
}
|
||||
if rm.noteCount("421") != 1 {
|
||||
t.Fatalf("notes on #421 = %d, want 1", rm.noteCount("421"))
|
||||
}
|
||||
if body := rm.issues["421"].notes[0]; !strings.Contains(body, "Report body: the turn ran.") ||
|
||||
!strings.Contains(body, "# REPORT - teststack - 421") {
|
||||
t.Errorf("note body wrong:\n%s", body)
|
||||
}
|
||||
|
||||
// REPORT file landed too.
|
||||
reports, err := filepath.Glob(filepath.Join(cfg.ReportDir, "REPORT-teststack-421-*.md"))
|
||||
if err != nil || len(reports) != 1 {
|
||||
t.Fatalf("REPORT files = %v err=%v", reports, err)
|
||||
}
|
||||
|
||||
// Second scan: our own note bumped updated_on, but the refresh must
|
||||
// have advanced the dedup marker -> no re-dispatch.
|
||||
f2 := newFakeLLM(t, textMsg("must not run"))
|
||||
cfg.LiteLLM.BaseURL = f2.srv.URL
|
||||
out2 := runOneScan(t, cfg, false)
|
||||
if f2.requestCount() != 0 {
|
||||
t.Fatalf("second scan re-dispatched (LLM calls = %d)", f2.requestCount())
|
||||
}
|
||||
if !strings.Contains(out2, "0 new/updated") {
|
||||
t.Errorf("second scan should find nothing new:\n%s", out2)
|
||||
}
|
||||
if rm.noteCount("421") != 1 {
|
||||
t.Errorf("second scan added notes: %d", rm.noteCount("421"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopRedispatchOnIssueUpdate(t *testing.T) {
|
||||
rm := newFakeRedmine(t)
|
||||
rm.addIssue("421", "First edit", "In Progress", "2026-08-28T21:30:00Z")
|
||||
f := newFakeLLM(t, textMsg("first reply"))
|
||||
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
||||
|
||||
runOneScan(t, cfg, false)
|
||||
if f.requestCount() != 1 {
|
||||
t.Fatalf("first scan LLM calls = %d", f.requestCount())
|
||||
}
|
||||
|
||||
// PMO updates the issue (bumps updated_on): the loop re-releases it.
|
||||
rm.setUpdatedOn("421", "2026-08-28T22:30:00Z")
|
||||
out := runOneScan(t, cfg, false)
|
||||
if !strings.Contains(out, "1 new/updated") {
|
||||
t.Errorf("updated issue should be new:\n%s", out)
|
||||
}
|
||||
if f.requestCount() != 2 {
|
||||
t.Errorf("LLM calls after update = %d, want 2", f.requestCount())
|
||||
}
|
||||
if rm.noteCount("421") != 2 {
|
||||
t.Errorf("notes after update = %d, want 2", rm.noteCount("421"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopTurnFailureRecordedNoHotLoop(t *testing.T) {
|
||||
rm := newFakeRedmine(t)
|
||||
rm.addIssue("421", "Broken task", "In Progress", "2026-08-28T21:30:00Z")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "proxy down", http.StatusBadGateway)
|
||||
}))
|
||||
defer srv.Close()
|
||||
cfg := loopTestConfig(t, rm.srv.URL, srv.URL)
|
||||
|
||||
out := runOneScan(t, cfg, false)
|
||||
if !strings.Contains(out, "turn failed") {
|
||||
t.Errorf("output should record the failed turn:\n%s", out)
|
||||
}
|
||||
// No note, no status change on a failed turn...
|
||||
if rm.noteCount("421") != 0 || rm.status("421") != "In Progress" {
|
||||
t.Errorf("failed turn must not note/status: notes=%d status=%s", rm.noteCount("421"), rm.status("421"))
|
||||
}
|
||||
// ...and the next scan must NOT retry it (updated_on marker consumed).
|
||||
out2 := runOneScan(t, cfg, false)
|
||||
if !strings.Contains(out2, "0 new/updated") {
|
||||
t.Errorf("failed turn hot-looped:\n%s", out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDryRunDispatchesNothing(t *testing.T) {
|
||||
rm := newFakeRedmine(t)
|
||||
rm.addIssue("421", "Would-be task", "In Progress", "2026-08-28T21:30:00Z")
|
||||
f := newFakeLLM(t, textMsg("unused"))
|
||||
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
||||
|
||||
out := runOneScan(t, cfg, true)
|
||||
if f.requestCount() != 0 {
|
||||
t.Errorf("dry-run made %d LLM calls", f.requestCount())
|
||||
}
|
||||
if !strings.Contains(out, "would dispatch 421") {
|
||||
t.Errorf("dry-run should print the would-be dispatch:\n%s", out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.Loop.StateDir, "loop.jsonl")); !os.IsNotExist(err) {
|
||||
t.Errorf("dry-run must not write loop state")
|
||||
}
|
||||
// A dry-run must not consume the task: a real scan still dispatches.
|
||||
runOneScan(t, cfg, false)
|
||||
if f.requestCount() != 1 {
|
||||
t.Errorf("LLM calls after real scan = %d, want 1", f.requestCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopStateSurvivesRestart(t *testing.T) {
|
||||
rm := newFakeRedmine(t)
|
||||
rm.addIssue("421", "Persisted", "In Progress", "2026-08-28T21:30:00Z")
|
||||
f := newFakeLLM(t, textMsg("reply"))
|
||||
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
||||
|
||||
runOneScan(t, cfg, false)
|
||||
|
||||
// New conductor = process restart: state reloads from loop.jsonl, and
|
||||
// the refresh line (post-note updated_on) is what dedups.
|
||||
f2 := newFakeLLM(t, textMsg("must not run"))
|
||||
cfg.LiteLLM.BaseURL = f2.srv.URL
|
||||
runOneScan(t, cfg, false)
|
||||
if f2.requestCount() != 0 {
|
||||
t.Errorf("restart re-dispatched a processed issue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopStatusMapMissLeavesStatus(t *testing.T) {
|
||||
rm := newFakeRedmine(t)
|
||||
rm.addIssue("421", "Unmapped status", "Review", "2026-08-28T21:30:00Z")
|
||||
f := newFakeLLM(t, textMsg("reply"))
|
||||
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
||||
|
||||
out := runOneScan(t, cfg, false)
|
||||
if strings.Contains(out, "status Review ->") {
|
||||
t.Errorf("unmapped status must not transition:\n%s", out)
|
||||
}
|
||||
if rm.status("421") != "Review" {
|
||||
t.Errorf("status changed: %q", rm.status("421"))
|
||||
}
|
||||
if rm.noteCount("421") != 1 {
|
||||
t.Errorf("note should still land, got %d", rm.noteCount("421"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopRequiresRedmineConfig(t *testing.T) {
|
||||
cfg := testConfig(t, "http://unused")
|
||||
cond, err := New(cfg, os.Stdout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = cond.RunLoop(context.Background(), LoopOpts{Once: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "[redmine] url is required") {
|
||||
t.Fatalf("err = %v, want missing-redmine error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopGiteaCommitStep(t *testing.T) {
|
||||
rm := newFakeRedmine(t)
|
||||
rm.addIssue("421", "Commit me", "In Progress", "2026-08-28T21:30:00Z")
|
||||
f := newFakeLLM(t, textMsg("reply with body"))
|
||||
|
||||
var mu sync.Mutex
|
||||
var commits []map[string]any
|
||||
var authHeaders []string
|
||||
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
|
||||
if r.Method == http.MethodGet {
|
||||
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
commits = append(commits, body)
|
||||
w.Write([]byte(`{"content":{"path":"x"}}`))
|
||||
}))
|
||||
defer gitea.Close()
|
||||
|
||||
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
||||
cfg.Gitea = config.GiteaConfig{
|
||||
URL: gitea.URL, KeyRef: "literal:gt-key",
|
||||
Owner: "ukrrs", Repo: "reports", Branch: "main",
|
||||
CommitReports: true,
|
||||
}
|
||||
out := runOneScan(t, cfg, false)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(commits) != 1 {
|
||||
t.Fatalf("gitea commits = %d, want 1", len(commits))
|
||||
}
|
||||
if authHeaders[0] != "token gt-key" {
|
||||
t.Errorf("gitea auth = %q", authHeaders[0])
|
||||
}
|
||||
path, _ := commits[0]["message"].(string)
|
||||
if !strings.Contains(out, "gitea: committed") {
|
||||
t.Errorf("output missing gitea commit line:\n%s", out)
|
||||
}
|
||||
if msg, _ := commits[0]["message"].(string); !strings.Contains(msg, "task 421") {
|
||||
t.Errorf("commit message = %q", msg)
|
||||
}
|
||||
_ = path
|
||||
if commits[0]["branch"] != "main" {
|
||||
t.Errorf("commit branch = %v", commits[0]["branch"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user