Files
MOPAC/internal/loop/daemon_test.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

416 lines
12 KiB
Go

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"])
}
}